Contributions & Rewards
NetOS rewards people for making shared network data more complete, more accurate, and fresher. Contribute useful information, earn points immediately, build recognition, and progress toward Bonusly rewards. Accountability runs in the background.
This document is both the design proposal and the reference for the shipped system. Sections marked Existing describe what NetOS already had; Proposed describes what this feature adds. Every number in a points, level, or reward table is an adjustable starting assumption, editable in Admin → Recognition without a deploy.
1. What exists today (Existing)
| Building block | Where | Reused for |
|---|---|---|
| Universal audit log: who, when, entity, before/after, changed fields | user_activity_logs, record_activity() | The single hook point for awarding points; the before/after evidence for every award |
| Site photos and documents with EXIF, GPS, heading, AI analysis, SharePoint source | infrastructure_documents, customer_documents | Photo quality checks and evidence links |
| Findings, comments, site links, provider records, customer records | routers under apps/api/app/api/v1 | Contribution types |
| Data-gap detection (missing address, coordinates, MRR, endpoints) | GET /api/v1/alerts | Missions are generated from these gaps |
| Bulk import pipeline with no per-row audit rows | services/circuit_import.py | Imports are naturally excluded from personal points |
| Roles and permission codes, seeded on first request | core/permissions.py | New view/admin permissions; status never grants access |
| Encrypted integration connectors with admin test buttons | integration_connector_settings | The Bonusly connector |
| In-process scheduler, weekly digest email, SMTP | main.py, services/activity_digest.py | Monthly recognition email and reward sweep |
User.department column (present, never populated) | users | Team leaderboards once populated from Microsoft 365 |
Gaps the design closes: no points or reputation model, no image duplicate detection, no "confirm this record" action, no reward ledger, no completeness view per record.
2. The contribution experience (Proposed)
Nothing new to learn. People keep doing what they already do; NetOS notices and responds:
- Immediate feedback in place. After an upload, edit, finding, or confirmation, the panel shows a quiet banner: "+12 points · Site photo with GPS and heading. Add a caption for +2." The same banner appears in the mobile field view.
- Contributed vs verified. Every record and photo shows two chips: *Contributed by A on date* and, when someone else has confirmed it, *Verified by B on date*. Unverified is the honest default; AI confidence never flips a record to verified.
- Confirm this record. A one-click action on infrastructure, customer, and provider records and on photos: "I checked this and it is correct." Optionally attach a note or evidence photo. You cannot confirm your own last change.
- Next actions where you work. Each record shows its missing important fields as small "Add coordinates (+10)" chips. These are the same rules that drive missions.
- My Contributions page: level and progress bar, lifetime and monthly points, badges, active missions, and a history where every entry explains what earned points, how many, what caps applied, and how to improve.
- Leaderboards page: monthly, all-time, by category (Photos, Records, Customers, Vendors, Verification), and by team when departments are populated. Names and counts only, never restricted record details. Includes "Most improved" and "Verifier of the month" so newer users can appear.
- Missions: three to five live missions generated from real gaps ("Add coordinates to 40 customer sites", "Update NOC phone for 12 providers", "Photograph 8 sites with no photo in 12 months") with a 1.5x multiplier while active. Admins can add custom missions from predefined gap types.
- Recognition: a monthly email to all users with the top contributors per category, badges earned, and team milestones. Modest by design: one email, no pop-ups.
3. Points table (adjustable starting assumptions)
Points reward usefulness, completeness, evidence, and operational importance. One clear photo beats five similar ones. A meaningful correction beats a partial new record.
| Rule key | Contribution | Base | Bonuses | Caps |
|---|---|---|---|---|
photo.site | Site photo passes checks (image opens, not a near-duplicate of a photo on the same site) | 8 | +3 GPS · +2 heading · +2 caption/comment · +2 placed in a photo folder · +3 first photo on a site in 12 months | 40 pts/site/day, 120 pts/day |
photo.sharepoint_ingest | Photo ingested from SharePoint | 3 | same bonuses, halved | 25 files/batch already capped; 60 pts/day |
document.site | Document (permit, floor plan, invoice) with title and type | 6 | +2 comment · +4 AI extracted key dates or parties | 60 pts/day |
record.create | New infrastructure, customer, or provider record with all required fields | 15 | +2 per recommended field filled at creation, up to +20 | 3 creates/day count at full value, then 1/3 |
record.fill | Fill an empty important field on an existing record | 4 per field | x2 for coordinates, address, NOC phone, account manager, contract dates | 40 pts/entity/day |
record.correct | Change an existing important field value | 6 per field | +6 when the record was untouched for 180+ days · +10 when later confirmed by someone else | flip-flop guard, see §5 |
record.refresh | Confirm an existing record correct without changes (180+ days since last touch) | 5 | 1 per entity per 90 days | |
vendor.contact | Add or update provider contact, escalation path, portal, ASN, W-9 | 6 per field | 40 pts/provider/day | |
finding.create | Finding with at least one photo and a narrative | 12 | +4 severity high or critical · +4 when shared or ticketed | 5/day at full value |
finding.resolve | Mark a finding resolved with a note | 6 | ||
verify.confirm | Confirm someone else's record or photo | 3 | +2 with evidence photo | 15/day; reciprocal guard, see §5 |
verify.corroborated | Your contribution was confirmed by another user | 5 | once per contribution | |
link.sharepoint | Add a resolvable SharePoint or OneDrive folder link | 4 | 3/site | |
comment.useful | Comment on a finding or photo, 40+ characters | 1 | 5/day | |
property.link_tenant | Manually link a commercial customer to a property ("Add tenant") | 3 | 30 pts/user/day | |
property.group_apply | Apply a reviewed address-scrub proposal, creating/updating a property and linking its member customers | 12 | +1 per additional tenant beyond 2, up to +8 (Milkyway's 19 members = +8) | 60 pts/user/day |
mission.multiplier | Any of the above while it satisfies an active mission | x1.5 |
Network records (circuits, RF links, PtMP nodes, electrical services, subtended links) reuse the rules above rather than adding new ones: create_*/update_* score under record.create/record.fill/record.correct with a per-entity-type FIELD_IMPORTANCE list (app/services/contributions/fields.py), same high-value doubling, guards, and caps as infrastructure/customer records. RF link and electrical service document uploads score under photo.site/document.site, but at a reduced bonus set: these tables (RFLinkDocument, ElectricalServiceDocument) are bare -- title, filename, path, comment, no GPS/dhash/content-type -- so only the base points and a caption/comment bonus apply (image vs. document is inferred from the filename extension), and dedupe is a content hash of the stored file rather than dHash near-duplicate matching. Circuit and infrastructure agreement PDF uploads (upload_circuit_agreement, upload_infrastructure_agreement) score under document.site the same way; applying an agreement extraction's approved fields onto a circuit (apply_circuit_agreement_extraction) rides the normal record.fill/record.correct path for whatever fields it changes overlap FIELD_IMPORTANCE["circuit"].
Not scored: bulk imports, deletes, edits by Integration: roles or API tokens, admin RBAC changes, AI re-runs, reads.
Levels (lifetime points, never decrease). Since 2026-09-30 they're Xander ranks, named for Xiber's cheetah mascot; the thresholds didn't change:
| Level | Rank | Threshold | Daily goal | Meaning |
|---|---|---|---|---|
| 1 | Cub | 0 | 20 | Signed in |
| 2 | Sprinter | 100 | 40 | A few real contributions |
| 3 | Pacer | 400 | 60 | Regular photos and records |
| 4 | Cheetah | 1,000 | 80 | Keeps records current; some verified corrections |
| 5 | Blur | 2,500 | 100 | Sustained accuracy; 25+ corroborated contributions |
| 6 | Xander | 6,000 | 120 | Long-term stewardship |
The daily goal is shown as a ring on My Contributions and celebrated. It never earns points: Stephen chose no bonus points for goals or streaks.
Levels 4 and above also require a minimum accuracy signal: at least 10 corroborated contributions and fewer than 10 percent reversed. Status is recognition only. It never grants permissions.
Badges (starting set): First Light (first photo), Field Eye (25 photos with GPS and heading), Cartographer (10 coordinate fixes), Fixer (10 corrections later confirmed), Fact Checker (25 confirmations), Vendor Whisperer (10 provider contact updates), Steady Hand (contributions in 8 of the last 12 weeks), Mission Complete (per mission), Team Milestone (department reaches a shared target).
4. Automatic awards and corroboration (Proposed)
Where points come from. The scoring engine runs inside record_activity(), the function every write endpoint already calls. It maps the audit action and entity type to a rule, evaluates lightweight eligibility checks, and writes a ledger row in the same transaction as the change. No queue, no reviewer.
Eligibility checks are not accuracy checks. They confirm required information is present, formats are valid, and the contribution is not an obvious duplicate. The event is labeled *contributed*.
Corroboration adds points later, only from independent signals:
- Another user confirms the record or photo via *Confirm this record*.
- Another user edits the record and leaves the contributed field unchanged for 90 days (silent confirmation, half value).
- An authoritative source matches: XSurvey pushes a survey for the site, a Sonar account matches the customer, geocoding agrees with entered coordinates within 50 m.
The contributor cannot confirm their own last change. AI confidence is recorded but never counts as corroboration.
5. Safeguards (lightweight, background)
| Risk | Guard |
|---|---|
| Duplicate awards | Every ledger row has a unique dedupe_key (rule, entity, field or photo hash, day). A second identical event is withheld, not paid. |
| Repeated photo uploads | 64-bit difference hash stored per image at upload; Hamming distance ≤ 6 against photos on the same site in the last 12 months = near-duplicate, 0 points, labeled "similar to photo X". |
| Trivial edit farming | Same user, same entity and field, more than 3 edits in a day: first pays, rest 0. A value returning to any value it held in the last 30 days pays 0. |
| Create then fix | Corrections by the creator within 7 days of creation pay 0. |
| Reciprocal verification | A confirms B and B confirms A more than twice in 14 days: later confirmations withheld and a flag recorded. |
| Bulk imports | Import commits do not pass through record_activity; the importer earns one import.commit event of 10 points regardless of row count. |
| Volume without value | Daily and per-entity caps above; leaderboards weight verified contributions and category breadth, not raw counts. |
| Velocity anomalies | Nightly job flags users above 3 standard deviations of their own 8-week mean for one category. A flag is a note for occasional review, not an alert. |
Administrators inspect flags in Admin → Recognition → Audit, and can reverse any event with a recorded reason. A reversal writes a mirrored negative ledger row; nothing is deleted. Honest corrections need no admin: undoing your own mistake simply pays 0.
6. Data model and audit trail (Proposed)
contribution_events -- the ledger, append-only
event_id, user_id, user_email, occurred_at
rule_key, rule_version, category, points, multiplier, breakdown JSONB
entity_type, entity_id, field_key, evidence JSONB (document ids, verification id)
source_activity_id -> user_activity_logs (before/after live there)
status (awarded | withheld | reversed), withheld_reason, dedupe_key UNIQUE
reversal_of -> contribution_events, reversed_by, reversal_reason, reversed_at
mission_id (nullable)
scoring_rules -- editable rules, versioned
rule_key, version, label, category, config JSONB (base, bonuses, caps, cooldowns), enabled, updated_by
record_verifications -- "I confirmed this"
verification_id, entity_type, entity_id, field_key (nullable), verifier_user_id, note, evidence_document_id, verified_at
contributor_event_id (the event being corroborated, nullable)
user_reputation -- materialized, recomputable from the ledger
user_id, lifetime_points, monthly_points (current month), level, corroborated_count, reversed_count, streak_weeks, updated_at
badges / user_badges -- catalog + awards (badge_key, awarded_at, evidence)
missions / mission_progress -- gap-type, target, multiplier, starts/ends, per-user progress
contribution_flags -- user_id, flag_type, detail JSONB, opened_at, reviewed_by, resolution
reward_accounts -- separate from reputation
user_id, reward_points_accrued, reward_points_redeemed, reward_points_held, updated_at
reward_redemptions
redemption_id, user_id, milestone_points, reward_units, status (eligible | processing | delivered | failed | held)
idempotency_key UNIQUE, external_id (Bonusly bonus id), attempts, last_error, created_at, delivered_at, held_reason
reward_settings -- singleton: conversion, per-user monthly cap, company monthly budget, payouts_enabledEverything is explainable after rules change because each event stores the rule version and the breakdown that produced its points.
7. Bonusly fulfillment and budget (Proposed, verified against Bonusly docs 2026-09)
What Bonusly offers today: a REST API at https://bonus.ly/api/v1/ authenticated with a Personal Access Token (Bearer, max 1-year expiry, scoped, for example recognition:write). POST /bonuses creates a bonus with a reason string containing the recipient mention and amount, and admin tokens may set giver_email. Users expose giving_balance, earning_balance, lifetime_earnings. Redemptions are listable. There is no "company points" endpoint; bonuses are funded from a giver's allowance.
Design. NetOS uses a dedicated Bonusly giver account (for example netos-rewards@xiber.net) whose monthly allowance is the company budget. NetOS never pays users directly. It gives Bonusly points from that account with a reason such as "+50 @jane NetOS data steward reward: 500 contribution points #dataquality".
Two ledgers. Lifetime reputation and levels never decrease when rewards are redeemed. A separate reward balance accrues 1 reward point per awarded contribution point, minus reversals. Redemption debits the reward balance only.
Conversion and limits (assumptions):
| Setting | Starting value |
|---|---|
| Milestone | every 500 reward points |
| Reward per milestone | 50 Bonusly points (about $50 at Bonusly's default 1 point = $1; set to match your account) |
| Per-user cap | 3 milestones per calendar month |
| Company budget | 1,500 Bonusly points per month, hard cap |
| Payouts | disabled until connector test passes and budget is set |
Flow. A nightly sweep converts eligible balances into reward_redemptions rows (status eligible). If payouts are enabled and budget remains, it posts each one to Bonusly with an idempotency key, moving it to processing then delivered with the Bonusly bonus id. Failures retry up to 5 times with backoff, then stay failed for an admin to retry or hold. Budget exhaustion holds only the redemptions that do not fit, which become eligible again next month. A user with an open flag has their redemptions held, not deleted. If a reversal drives the reward balance negative, future accrual repays the deficit before new milestones.
If automatic delivery is unavailable (no admin token, plan without API access): Admin → Recognition → Rewards offers a batch export of eligible redemptions as CSV (email, amount, reason, idempotency key) and a "mark delivered" action that records the external reference. The same states and idempotency apply.
Users see their reward balance, progress to the next milestone, and each redemption as eligible, processing, delivered, or held with the reason.
8. Permissions and privacy
contributions_view(see own history, leaderboards, missions): granted to every human role.verifications_edit(confirm records): same roles that can edit the underlying entity.recognition_admin(rules, missions, badges, rewards, flags, reversals): Super Admin only by default.- Leaderboards and the recognition email show display names, points, counts, and categories only. Missions link to records, and the link respects the viewer's existing permissions.
Integration:roles and API-token principals never earn points or appear on boards.- Any user can opt themselves out of every leaderboard/ranking surface (
User.exclude_from_leaderboard, §18) -- they still earn points, levels, badges, and rewards, they just never appear to anyone else.
9. Success measures
Tracked in Admin → Recognition → Insights, monthly:
- Completeness: share of infrastructure with coordinates and a photo in the last 12 months; share of customers with a contact and coordinates; share of providers with NOC phone and account manager.
- Accuracy: corroborated contributions as a share of contributions; reversal rate.
- Freshness: share of records touched or confirmed in the last 180 days.
- Participation: distinct contributors per month; share of active users with at least one contribution; contributors below level 3 who appeared on a board.
Submission volume is shown but never used as a headline metric.
10. Implementation plan
| Phase | Scope | Status |
|---|---|---|
| 1. Ledger and engine | contribution_events, scoring_rules with defaults, engine hooked into record_activity, photo hash dedup, guards, user_reputation, levels, My Contributions page, leaderboards, inline award banners, admin rules editor, audit search, reversal | MVP |
| 2. Verification and missions | record_verifications and Confirm action, corroboration bonuses, reciprocal guard, contributed/verified chips, missions from gap rules, badges, department sync from Microsoft 365, monthly recognition email | MVP |
| 3. Rewards | Reward accounts and redemptions, settings and budget, Bonusly connector and client, nightly sweep, automatic fulfillment (disabled by default), batch CSV export, user reward progress, admin holds and retries, insights panel | MVP |
| 4. Later | Silent-confirmation and authoritative-source corroboration, team milestones with celebrations, velocity flags tuning, per-record completeness score on list pages | Post-MVP |
Every phase ships behind existing test, lint, typecheck, and build gates and follows the deploy runbook.
11. Implementation notes (Phase 1)
Shipped on branch feature/contributions-phase1, migration 0064_contributions_phase1. This section documents what actually landed, so it stays authoritative alongside the design above rather than duplicating it.
Hook point. app.services.contributions.engine.score_activity is called from inside app.services.audit.record_activity immediately after the UserActivityLog row is built (same session/transaction), and returns the ContributionEvent it wrote (or None) so the calling route can attach contribution: {...} to its JSON response. record_activity gained an explicit activity_id (generated up front instead of left to the ORM) and an optional extra: dict kwarg for evidence the engine needs but that doesn't belong in the audit before/after payload (an image's dHash, a parent site id, {"skip_scoring": True} for a caller that already scored the write itself). Both changes are backward compatible — no existing call site passes or reads either. Two write paths don't go through record_activity at all and call the engine directly: app.api.v1.imports's three commit routes (score_import_commit, one import.commit event per job) and site_links.ingest_site_link's per-file loop (score_activity with action="ingest_sharepoint_document", once per ingested file; the loop's own single per-batch record_activity call passes extra={"skip_scoring": True} to avoid double-counting, and the route sums the per-file events into one contribution envelope via aggregate_contribution_payload).
Allowlist, not a denylist. engine.SCORED_ACTIONS maps only the specific (action -> handler) pairs below; any action not in that dict — every bulk_update_*/bulk_delete_*/delete_*/reanalyze_*, reveal_portal_credentials, update_cost_classification, subtended-link and classification actions, all authz/settings/integration actions, and reverse_contribution itself — is never scored, by construction, rather than by remembering to exclude it.
Rule keys shipped (app/services/contributions/rules.py::DEFAULT_RULES; each also seeded into the scoring_rules table on first use and editable from Admin → Recognition):
| rule_key | Mapped from | category |
|---|---|---|
photo.site | upload_infrastructure_document / upload_customer_document, image content type | photos |
photo.sharepoint_ingest | ingest_sharepoint_document (site_links ingest loop, image) | photos |
document.site | upload/ingest, non-image content type | photos |
record.create | create_infrastructure / create_customer / create_provider | records / customers / vendors |
record.fill, record.correct | update_infrastructure / update_customer (non-carrier field changes) | records / customers |
record.refresh | update_infrastructure / update_customer with no changed fields and extra.confirm set (hook only — Phase 2 wires the "Confirm this record" button that sets it) | records / customers |
vendor.contact | update_provider (carrier field changes) | vendors |
finding.create, finding.resolve | create_site_finding, update_site_finding (status → resolved) | findings |
link.sharepoint | create_site_link (SharePoint/OneDrive folder links only) | links |
comment.useful | create_comment (body ≥ 40 chars) | comments |
import.commit | called directly from app/api/v1/imports.py, not via record_activity | imports |
Config shape (also the PUT body for /contributions/rules/{rule_key}):
{
"base": 8,
"bonuses": [{"key": "gps", "label": "GPS", "points": 3, "condition": "gps_latitude and gps_longitude present"}],
"caps": {"per_user_per_day": 120, "per_entity_per_day": 40, "per_entity_total": null, "cooldown_days": null}
}per_entity_per_day/per_entity_total are keyed off the site (infrastructure/customer), not the child document/link row, for photo.*/document.site/link.sharepoint — the ledger row's own entity_type/entity_id is likewise the site for those three rules (the document/link id lives in evidence), so a per-site daily budget is actually enforceable; every other rule keys its own entity directly (the infrastructure asset, customer, carrier, finding, or comment).
Field importance (app/services/contributions/fields.py::FIELD_IMPORTANCE): coordinates (synthetic — either latitude or longitude changing) and address_line_1/term_end_date (infrastructure) or term_start_date (customer) are the "high-value" fields that pay double under record.fill; the remaining infra/customer fields (facility_provider_id, site_type, monitoring_url, mrc_usd, effective_date / contact_name, contact_email, contact_phone, contracted_mrr_usd) pay the base rate. Carrier fields (noc_phone_24x7, account_manager_*, escalation_path, nms_portal_url, asn, billing_contact, legal_contact, tax_id, w9_url) route through vendor.contact instead of record.fill/record.correct — flat rate regardless of fill vs. correct, matching the design table. A record update aggregates every qualifying field from one PATCH into a single ledger event (one record.fill or record.correct row per call, breakdown itemizing each field) rather than one row per field — a Phase 1 simplification; the design's rule:entity:field:YYYY-MM-DD dedupe format was a starting point, not the final word — see Guards below for why.
Guards implemented: dedupe-on-collision (any rule whose computed dedupe_key already exists in the ledger is written anyway, as status="withheld", withheld_reason="duplicate", 0 points, with a #<event_id> suffix so the unique constraint holds); near-duplicate photos (64-bit dHash, app.services.site_images.compute_image_dhash/hamming_distance_hex, Hamming ≤ 6 against non-deleted photos on the same site in the last 365 days); flip-flop (a field's new value matches any value it held in the last 30 days, scanned from user_activity_logs); creator-fix (a correction by the same user who created the record, within 7 days — requires InfrastructureAsset.created_by/Carrier.created_by to actually be populated at creation, which they weren't before this change; both create_infrastructure and create_service_provider now set it, matching what create_customer already did); edit-farming (more than 3 scored attempts today for the same user+entity+field, scanned from each day's ContributionEvent.breakdown/field_key); per-user and per-record daily point caps; a full-value-then-//3 reduction after the first 3 (record.create)/5 (finding.create) awards per user per day. Deviation from the literal design: record.fill/record.correct/vendor.contact dedupe keys include a random suffix rather than being purely rule:entity:field:date — a literal per-day key would make the 2nd and 3rd of the "first 3 edits pay" edit-farming allowance collide with the 1st as a false "duplicate" before edit-farming ever gets to act; the three explicit per-field guards (not the dedupe-key backstop) are records' real anti-gaming mechanism, matching how §5 describes them.
Levels and badges (app/services/contributions/levels.py): the six-level table and the level-4+ accuracy gate (≥10 corroborated, <10% reversed — corroboration itself is Phase 2, so in practice this gate is satisfied by having zero reversals and the count check is the only one that can bite) are implemented as designed. Badges evaluated in Phase 1: First Light, Field Eye, Cartographer, Vendor Whisperer, Steady Hand — all backed by badges/user_badges catalog rows seeded on first use, editable like rules. Fixer is seeded but enabled=False (it depends on Phase 2 corroboration and can never fire yet). Mission Complete and Team Milestone are not seeded — they're mission-system badges, entirely Phase 2.
Known Phase 1 simplifications (beyond the dedupe-key point above): document.site's ai_extracted bonus is never awarded, because AI analysis runs in a background task *after* the upload response (and therefore after scoring) — there is no ai_analysis yet at the moment the engine runs. finding.create's "shared or ticketed" bonus is likewise never awarded at creation (that state doesn't exist yet on a brand-new finding). Both are documented gaps, not bugs; picking them up would mean scoring on a background-task completion rather than a request/response, a bigger change than Phase 1's scope.
Endpoints: GET/PUT under /api/v1/contributions/ — me, leaderboard, events (admin search), events/{id}/reverse, rules, rules/{rule_key}, flags, flags/{id}/resolve, admin/backfill-dhash. Full detail in API & Integrations.
Permissions: contributions_view (every human role) and recognition_admin (Super Admin only) — detail in Authorization & RBAC.
Deferred to Phase 2: record_verifications and the "Confirm this record" action (the record.refresh hook exists but nothing sets extra.confirm yet), corroboration bonuses and the reciprocal-verification guard, contributed/verified chips, missions and the mission multiplier, department sync from Microsoft 365 (the leaderboard's department filter and departments_available flag work today, but only for whatever User.department values already exist), the monthly recognition email, and the Fixer/Mission Complete/Team Milestone badges.
Deferred to Phase 3: reward accounts, redemptions, the Bonusly connector, and everything else in §7.
Deploy notes: migration 0064_contributions_phase1 adds the six new tables plus image_dhash on infrastructure_documents/customer_documents (both nullable, indexed) — no backfill required for the app to function, since the near-duplicate check simply finds nothing for photos uploaded before this shipped. POST /api/v1/contributions/admin/backfill-dhash?limit= (default 500/call, safe to call repeatedly) computes image_dhash for existing photos from their already-stored files, so near-duplicate detection covers the pre-existing library too — run it a few times after deploy if the photo count exceeds one call's limit.
12. Implementation notes (Phase 2)
Shipped on branch feature/contributions-phase2, migration 0065_contributions_phase2. Builds directly on Phase 1's engine/ledger; this section documents what actually landed.
"Confirm this record." POST /api/v1/verifications (app/api/v1/verifications.py) resolves everything Phase 1 couldn't decide on its own — does the entity exist, can the caller view it, are they confirming their own last change, is this a repeat within 90 days, who (if anyone) gets corroborated, and whether this verifier/contributor pair is in reciprocal-guard territory — then calls record_activity(action="confirm_record", entity_type=..., entity_id=..., extra={...}). engine.SCORED_ACTIONS maps confirm_record to a new handler, _score_verify_confirm, which turns that resolved context into ledger rows: the verify.confirm event for the verifier, and (via the same _write_event dedupe-collision machinery Phase 1 already had, keyed verify.corroborated:<event_id>) the verify.corroborated bonus for the contributor being corroborated, awarded through a synthetic Principal built from the contributor's own user id/email rather than the caller's. "Contributed by" resolution (_resolve_last_contributor) prefers the most recent awarded contribution_events row on the entity and falls back to the latest user_activity_logs row when nothing was ever scored for it (pre-Phase-1 data, or an entity every prior write to which was a bulk import) — matching the design's "fallback: latest audit row" instruction.
Corroboration target selection (engine.find_corroboration_target, also used directly by the verifications router to populate record_verifications.contributor_event_id): the most recent *awarded* event on the entity, by someone other than the verifier, whose rule_key is in CORROBORATABLE_RULES (record.create, record.fill, record.correct, vendor.contact, photo.site, photo.sharepoint_ingest, document.site, finding.create — import.commit and the verification rules themselves are excluded), and that has no prior verify.corroborated event referencing it. A verification's field_key does not filter this search — the design calls corroboration "an independent signal that a record or photo is correct," not "this exact field was corroborated," and Phase 1's record.fill/record.correct events already aggregate every changed field from one write into a single ledger row (see Phase 1 notes), so there is no finer-grained "this field's event" to target anyway.
Reciprocal guard (§5): rather than adding a schema column to track "who did A confirm," every verify.confirm event (awarded *or* withheld) stores evidence.contributor_email — the person being confirmed — which is already necessary context for the corroboration/self-confirm logic. app.api.v1.verifications._reciprocal_pair_count sums, over the last 14 days, verify.confirm events where (verifier=A, contributor_email=B) plus events where (verifier=B, contributor_email=A); once that combined count reaches 2 (i.e. the *third* confirmation between the pair in either direction, in the window), the current confirmation is written as withheld/reciprocal_verification instead of awarded, and a contribution_flags row (flag_type="reciprocal_verification", detail={"pair_email": ...}) opens for each of the two users if one isn't already open for that pair within the window. A withheld reciprocal confirmation still records evidence.contributor_email, so the pattern remains visible to the *next* attempt even though it paid zero points.
`record.refresh` re-wiring. Phase 1 already implemented the record.refresh rule and its 90-day-per-entity cooldown, but wired it to a hook (extra.confirm on update_infrastructure/update_customer with no changed fields) that Phase 2's actual "Confirm this record" UI never uses — that hook is left in place (harmless, unreachable from the UI) rather than removed, to avoid touching Phase 1's record_update handler. The real Phase 2 trigger lives in _score_verify_confirm: when a confirmation has no field_key (a whole-entity confirm, not a per-photo one) and the entity's last audit activity (user_activity_logs, any actor) is 180+ days old, the verifier separately earns a record.refresh event via the same _score_record_refresh helper Phase 1 wrote.
Badges: fixer (seeded disabled in Phase 1) is now enabled, with a new evaluate_badges criteria kind, corroborated_rule_count — count of the user's own record.correct events that a verify.corroborated event (evidence corroborates_event_id) later referenced, ≥10 to earn it. fact_checker (new, 25 verify.confirm events) reuses the existing rule_count kind. mission_complete is not a single catalog badge; because the design calls for it "per mission" and user_badges has a UNIQUE(user_id, badge_key) constraint, each mission gets its own dynamically-created badges row keyed mission_complete:<mission_key> (created when the mission is generated/created, via missions.ensure_mission_badge) so a user who completes several different missions over time actually earns several distinct badge tiles, not one. team_milestone (design's other Phase-2-adjacent badge) is not implemented — no "shared department target" concept exists yet; deferred alongside Phase 3/4 items below.
Missions (app/services/contributions/missions.py). GAP_TYPES is a single registry (label template, entity type, category, the rule keys and optional field-key subset a mission of that type matches, and a generator coroutine) read by both generate_missions (candidate discovery) and the engine (event matching), so adding a gap type is one dict entry. The 8 gap types in the design are all implemented: infra_missing_coordinates, customer_missing_coordinates, customer_missing_contact, provider_missing_noc_phone, provider_missing_account_manager, infra_no_photo_12mo, infra_stale_180d, and records_unverified_top (ranked by contribution volume — the design says "records most worth double-checking," read here as "most-contributed," capped at 200 candidates before the mission's own 500-id cap). generate_missions(session, max_active=5) creates at most one active mission per gap type, skips any gap type that already has an active mission, requires ≥5 candidates, caps target_count at 40, and freezes up to 500 candidate ids into config.candidate_ids at generation time. The engine applies the multiplier generically inside _write_event (every rule, not just the ones the design calls out by name) whenever find_active_mission_for_event matches an awarded event's (rule_key, entity_type, entity_id, field_key) against an active mission's gap type and frozen candidate set — so the multiplier is order-independent with respect to caps (a rule's daily/per-entity cap is applied first, then the mission multiplier, then mission progress is recorded using the post-multiplier points). mission_progress is upserted per (mission_id, user_id) on every matching awarded event; when the sum of count across all participants reaches target_count, the mission is marked completed and every participant with count >= 1 receives the mission's badge.
Custom missions (POST /contributions/missions, recognition_admin) reuse the same gap-type generator as auto-generation, then optionally narrow the candidate set by a state filter (only meaningful for infrastructure_asset/customer gap types, which have a state column) — this is narrower than the design's open-ended "optional filter such as state," a deliberate simplification rather than a general filter DSL.
Department sync (app/services/contributions/department_sync.py): calls the Xiber gateway's msgraph.list_users tool (via the existing xiber_gateway integration_connector_settings connector — no new connector type) and updates users.department for case-insensitive email matches where Graph returned a non-empty department. Shared between the admin endpoint and the weekly scheduler call so there's one code path. departments_available and the leaderboard's department filter already existed in Phase 1; Phase 2 is the first thing that actually populates real data into them. The leaderboard also now returns teams: [{department, points, member_count}] alongside the individual rows (same query, aggregated), and /leaderboards gained an Individuals/Teams toggle.
Monthly recognition email (app/services/contributions/recognition_email.py): top 5 overall, top 3 per category, verifier of the month (highest verify.confirm points), most improved (same month-over-month-delta-among-under-level-3 logic as the leaderboard), badges earned, missions completed, and team totals for the given month_key — names, categories, and counts only, matching the leaderboard's privacy rule (§8); nothing renders an entity's own label/display name. Idempotent via a UserActivityLog row (event_type="report", action="monthly_recognition_sent", entity_type="recognition_email", entity_id=month_key), the same pattern activity_digest/renewal_reports already use. The scheduler block in main.py checks recognition_email_enabled/recognition_email_day (day-of-month, 1-28)/recognition_email_hour and sends for the *previous* calendar month once per month; the admin "Send now" button always targets the previous month too (or an explicit ?month=YYYY-MM) and forces a resend (bypasses the idempotency check, since it's an explicit admin action).
Scheduler additions (app/main.py::notification_scheduler): the same 30-minute loop that already handled the renewal report and activity digest gained three more checks, each with its own idempotency guard so they're safe under the loop's 30-minute polling cadence: the recognition email (monthly, via the UserActivityLog check above), generate_missions (daily, guarded by a missions_generated:<date>-shaped audit row via missions.missions_generated_today/mark_missions_generated), and department sync (weekly, guarded by a departments_synced:<iso-week>-shaped row via department_sync.departments_synced_this_week/mark_departments_synced, and skipped quietly — logged, not raised — when the gateway connector isn't configured).
Web: components/verification-chips.tsx (contributed-by/verified-by chips, the Confirm popover with an optional note, and — when showNextActions is set — the "Add coordinates (+8)" chips) is mounted on the infrastructure, customer, and provider detail page headers and per-photo in site-documents-panel.tsx's document viewer. components/missions-card.tsx renders on /contributions; recognition-admin.tsx gained a Missions tab (list, generate now, add custom from a gap type, archive, and a "Sync departments" button); notification-settings-admin.tsx gained the three recognition-email fields and a "Send Now (previous month)" button; leaderboards-workspace.tsx gained the Individuals/Teams toggle.
Known Phase 2 simplifications: the design's "optionally attach... an evidence photo" picker (browsing the entity's own photos) is not implemented on the entity-level Confirm popover — only a note field — because the popover is a small, generically-mounted client component that doesn't have easy access to the site's photo list without new prop plumbing through three different server-rendered detail pages; evidence_document_id is still a real, scored field on the API (the +2 bonus applies whenever a caller passes one), just not wired to a picker UI yet. Reversing a verify.confirm/verify.corroborated/mission-multiplied event (POST /contributions/events/{id}/reverse, unchanged from Phase 1) does not roll back mission_progress or un-complete a mission — same "nothing is deleted, reversals are additive ledger rows" model as Phase 1, just not yet mission-aware. record.fill/record.correct scoring for the synthetic coordinates field depends on app.api.v1.infrastructure._geo_lat_lon/app.api.v1.customers' equivalent successfully parsing the in-memory geom value with geoalchemy2.shape.to_shape, which requires the optional shapely package; shapely is not currently pinned in pyproject.toml's dependency list even though to_shape is imported and called unconditionally — a pre-existing Phase 1 gap (confirmed via this phase's own test suite, which routes its mission-multiplier test through a provider/vendor.contact field instead of coordinates to avoid it), not something Phase 2 introduced, but worth fixing separately since it silently zeroes out every coordinates fill/correct award and any mission built on infra_missing_coordinates/customer_missing_coordinates in this environment today.
Deferred to Phase 3: reward accounts, redemptions, the Bonusly connector, and everything else in §7. Silent-confirmation and authoritative-source corroboration (§4, "Later") and the team_milestone badge remain deferred to Phase 4/Later per §10.
Deploy notes: migration 0065_contributions_phase2 adds record_verifications, missions, mission_progress, a real FK on the already-existing contribution_events.mission_id column, and three notification_settings columns (recognition_email_enabled default false, recognition_email_day default 1, recognition_email_hour default 9) — recognition email is off by default until an admin opts in. No backfill required. After deploy, consider calling POST /contributions/missions/generate and POST /contributions/admin/sync-departments once manually (both are also idempotent-safe to just let the scheduler pick up within its normal cadence) so missions and team leaderboards have real data immediately rather than waiting for the next daily/weekly scheduler pass — and fix the shapely dependency gap above if coordinate-field missions/scoring matter in this environment.
13. Implementation notes (Phase 3)
Shipped on branch feature/contributions-phase3, migration 0066_contributions_phase3. Builds on Phase 1/2's ledger and engine; this section documents what actually landed against §7's design.
Schema. Four new tables: reward_settings (singleton row, id=1, get-or-created lazily by app.services.contributions.rewards.get_reward_settings rather than seeded by a migration data insert), reward_accounts (one row per user, materialized like user_reputation), reward_redemptions (the payout queue, one row per milestone), and reward_budget_months (one row per calendar month of Bonusly spend). Models in app/models/entities.py: RewardSettings, RewardAccount, RewardRedemption, RewardBudgetMonth.
Ledger math (app/services/contributions/rewards.py::recompute_reward_account). reward_accounts.accrued_points is the sum of the user's own contribution_events.points over rows with status in (awarded, reversed) — not a running counter. This falls out of how Phase 1's reverse_event already works: reversing an event flips the *original* row's status to reversed (points unchanged) and adds a *mirror* row (also status=reversed, with the negated points). Summing both statuses therefore nets every reversed pair to zero automatically, with no special-casing needed here — an event that was never reversed keeps contributing its points via status=awarded. deficit_points is derived, not independently written: max(redeemed_points + held_points - accrued_points, 0). When a reversal drops accrued_points below what was already redeemed, the shortfall shows up here; it self-heals as future accrual raises accrued_points back past it — "future accrual pays deficit first" holds without any extra bookkeeping. available_points() is max(accrued_points - redeemed_points - held_points, 0), computed on read rather than stored. Hooked into the engine at both places that change a user's awarded/reversed total: engine._write_event (after an awarded event) and engine.reverse_event (after a reversal) — both via a lazy import to avoid a module-load cycle, since engine.py otherwise never imports from rewards.py.
`held_points` exists in the schema (per §7's data model) but nothing writes it in this phase — it's reserved for a possible future manual per-user hold lever distinct from reward_redemptions.status='held' (which is what actually implements "held" today, via the redemption rows themselves). Always 0.
Sweep (rewards.sweep_eligibility). For every reward_accounts row with available_points() >= milestone_points: computes how many full milestones the balance supports (available // milestone_points), caps that by per_user_monthly_cap minus the user's existing non-failed redemptions this month_key, and creates that many reward_redemptions rows — idempotency_key = f"reward:{user_id}:{milestone_index}" where milestone_index starts at redeemed_points // milestone_points (so it always resumes from the next never-redeemed milestone, even across months). redeemed_points is incremented immediately for every row created (eligible *or* held) so the same points can never fund two payouts, matching §7. A user with an open (unresolved) contribution_flags row gets held/open_flag redemptions instead of eligible ones. The same call also un-holds: open_flag holds whose user no longer has an open flag, and budget_exhausted holds whose stored month_key is not the current one (the month rolled over, so the budget check should be retried fresh) — both flip back to eligible.
Fulfillment (rewards.fulfill). No-op unless reward_settings.payouts_enabled and fulfillment_mode == 'auto' and the bonusly connector row exists, is enabled, and has a token. First: any redemption stuck in processing for more than an hour (a crash mid-call, since a real outcome is never knowable after the fact) is marked failed with last_error="unknown outcome; verify in Bonusly before retry" — this is the only path that produces that message, and it requires an explicit admin retry. Then, for each eligible redemption ordered by created_at: skip it if backed off (attempts > 0 and less than 2**attempts hours since processing_at); hold it as budget_exhausted if reward_budget_months[month].units_committed + reward_units would exceed company_monthly_budget_units (this holds only the redemptions that don't fit, not the whole queue, since the budget check runs per-row against a running units_committed total); otherwise set processing/bump attempts/stamp processing_at *before* calling Bonusly (so a crash mid-call is caught by the stale-processing check above rather than silently retried into a duplicate bonus), then call BonuslyClient.create_bonus. Success → delivered, external_id/external_url from the response, budget's units_committed/units_delivered both increase. Failure → last_error set; failed at 5 attempts, otherwise back to eligible for the next sweep's backoff-gated retry.
Bonus reason string: "NetOS data steward reward: {milestone_points} contribution points {hashtag}" (the design's illustrative "+50 @jane.doe ... #dataquality" shape is what BonuslyClient.create_bonus builds by prefixing +{amount} @{handle} when the caller's reason doesn't already contain the @handle mention — the rewards service always passes a mention-free reason, so the client adds it). Both receiver_email and the @handle mention (the email's local part) are sent, per §7's "send both to be safe."
Bonusly client (app/services/bonusly.py::BonuslyClient) follows the same urllib-only, no-new-dependency shape as app.services.xiber_gateway.XiberGatewayClient: test_connection(giver_email=None) calls GET /users/me and, if a giver email is configured, GET /users?email= to report that account's giving_balance in the test message; create_bonus(...) posts /bonuses; get_bonus(id) reads one back. BonuslyError(message, status_code) mirrors XiberGatewayError. Every test in tests/test_contributions_phase3.py monkeypatches urllib.request.urlopen — the real Bonusly API is never called, matching the "live connector stays disabled, payouts stay off by default" requirement.
Connector: bonusly added to ConnectorType/CONNECTOR_TYPES in app/api/v1/integrations_admin.py, config keys giver_email (optional) and last_test_ok_at (written automatically on a successful Test, read by the settings-PUT validator below), secret = the Bonusly personal access token. components/integrations-admin.tsx gained a BonuslyCard (token, giver email, Enabled toggle, Test button reporting the giver balance in its message) alongside the existing Microsoft Graph and Xiber Gateway cards.
Settings validation (PUT /contributions/rewards/settings): all the numeric fields are Pydantic ge=0/ge=1 constrained. payouts_enabled=true is rejected with a 422 unless fulfillment_mode == 'batch', or the bonusly connector is enabled *and* its config.last_test_ok_at (set by integrations_admin.test_connector on a successful Bonusly test) is within the last 30 days — exactly the design's "connector test passes... within 30 days... or fulfillment_mode == 'batch'" rule.
Batch fulfillment (when auto delivery is unavailable): GET /contributions/rewards/export.csv?status= streams idempotency_key, user_email, reward_units, milestone_points, month_key, reason for redemptions in that status (default eligible). POST /contributions/rewards/redemptions/{id}/mark-delivered ({external_id?, note?}) sets delivered and — since the batch path never goes through fulfill()'s budget accounting — also increments reward_budget_months for that redemption's month, so budget stays accurate regardless of which fulfillment path actually delivered a given redemption. hold/{reason}, release, and retry (failed → eligible, attempts/last_error/processing_at reset) round out manual control. All five of these plus update_reward_settings are recorded via record_activity but intentionally never scored: engine.SCORED_ACTIONS is an allowlist (see Phase 1 notes), so simply not adding these action names to it is sufficient — no separate "never-scored set" exists to maintain.
API (app/api/v1/contributions.py, same router/prefix as Phases 1–2): GET /contributions/rewards/me (contributions_view), GET/PUT /contributions/rewards/settings, GET /contributions/rewards/redemptions?status=&month=&user_email=, GET /contributions/rewards/budget, POST /contributions/rewards/sweep (runs sweep then fulfill immediately, returns both result dicts), GET /contributions/rewards/export.csv?status=, POST /contributions/rewards/redemptions/{id}/{mark-delivered,hold,release,retry} (all recognition_admin), and GET /contributions/insights (recognition_admin, the §9 measures, cached in-process for 10 minutes via a module-level dict — ?force=true bypasses the cache). app/core/permissions.py::ROUTE_PERMISSION_PREFIXES gained explicit /contributions/insights and /contributions/rewards (with /contributions/rewards/me carved out as contributions_view) entries ahead of the existing /contributions catch-all, since without them the catch-all's contributions_view would have under-protected the new admin routes.
Insights (§9 measures, GET /contributions/insights): completeness is computed directly from the underlying tables rather than the ledger — infrastructure assets with geom set and at least one non-deleted image document in the last 365 days; customers with geom set and any of contact_name/contact_email/contact_phone; providers (Carrier) with noc_phone_24x7 and either account_manager_name or account_manager_email. Accuracy: corroborated_share_pct is awarded verify.corroborated events over awarded events on engine.CORROBORATABLE_RULES; reversal_rate_pct is reversed *original* rows (status='reversed' and reversal_of IS NULL, i.e. excluding the mirror rows) over all original scored rows (status IN (awarded, reversed) and reversal_of IS NULL). Freshness: distinct infrastructure/customer entities touched by a user_activity_logs row or a record_verifications row in the last 180 days, over the total live row count of both tables. Participation: distinct non-integration contributors this calendar month; that count divided by users with any user_activity_logs row in the last 90 days (the "active users" denominator); distinct contributors this month who are also user_reputation.level < 3 (a proxy for "appear on the monthly board" — the literal design language — since the leaderboard itself is already capped at the top 25 and doesn't persist board membership anywhere to join against). submission_count_this_month is returned but, per the design, is not surfaced as a headline number in the admin UI (shown last, in small print).
Web: components/rewards-card.tsx (client-fetched, mounted on /contributions via contributions-workspace.tsx) shows available points, a progress bar to the next milestone, the monthly cap, a disabled-payouts note, and each redemption with a status chip (eligible/processing/delivered-with-Bonusly-link/held-with-reason/failed). recognition-admin.tsx gained Rewards (settings form with every assumption labeled, a budget meter for the current month, a status-filterable redemptions table with Hold/Release/Retry/Mark delivered actions, an Export CSV link, and a Sweep now button) and Insights tabs (the §9 measures as KPI tiles, submission count shown last in small print — no by-month table, since a single cached snapshot is cheap but a full monthly history would need its own aggregation table this phase doesn't add).
Known Phase 3 simplifications: held_points is schema-only (see above). Insights has no historical by-month table — only the current cached snapshot — because that would need a new aggregation/rollup table the design didn't ask for explicitly ("a small table by month if cheap" was conditional, and computing it live from the ledger for arbitrary past months was judged not cheap enough to do by default). The CSV export and mark-delivered endpoints don't cross-check that a redemption wasn't *also* mid-flight in the auto path — this is fine in practice because fulfillment_mode is a single global switch (a deployment is either auto or batch, not both at once) and mark-delivered is rejected with a 422 on a redemption that's already delivered.
Deploy notes: migration 0066_contributions_phase3 adds reward_settings, reward_accounts, reward_redemptions, and reward_budget_months — no backfill required; reward_accounts rows are created lazily by recompute_reward_account the next time each user earns a scored contribution (existing lifetime points are *not* retroactively converted into reward balances on deploy day, since accrued_points is computed from the ledger going forward each time the hook fires — the first post-deploy awarded event for a user will pick up their full historical ledger sum, so nothing is lost, it just doesn't populate until then). Payouts stay off (payouts_enabled=false) until an admin configures the bonusly connector, tests it successfully, and turns settings on — or switches to batch mode and works the CSV export/mark-delivered flow by hand. The nightly scheduler block (rewards_swept:<date>, same UserActivityLog-row idempotency pattern as Phase 2's missions_generated/departments_synced) runs sweep-then-fulfill once per day regardless of payouts_enabled (the sweep itself is harmless and useful even with payouts off, since it's what populates the /contributions Rewards card's redemption list before a program is funded) — POST /contributions/rewards/sweep is available to run it on demand after deploy rather than waiting for the next scheduler tick.
14. Implementation notes (Historical replay)
Shipped alongside Phase 3 on branch feature/contributions-replay. The engine only scores writes made *after* it went live, from inside record_activity's hook — every user_activity_logs row written before that (or written since by a code path that predates a newly-scored action) was never scored. app/services/contributions/replay.py::replay_audit_history(session, *, since, until=None, reward_eligible=False, dry_run=True, limit=None) lets an admin backfill points for a window of that history, with each event's occurred_at set to the *original* activity's timestamp (not "now"), so reputation, levels, and the leaderboard reflect the real history rather than a single spike on the day the backfill ran.
Time injection. engine.score_activity gained now: datetime | None = None (defaults to datetime.now(UTC), live scoring's existing behavior) and replayed: bool = False, both threaded down through every private _score_* handler and _write_event, which sets occurred_at = now and — new — merges evidence["replayed"] = True into whatever evidence the rule already writes when replayed=True. Every dedupe key that embeds a date (e.g. record.fill:...:{now.date().isoformat()}:...) already took now as a parameter, so backdated replay rows land in the correct historical "day" for cap/dedupe purposes rather than colliding with (or being capped alongside) today's live activity.
Selection. Only rows with event_type='audit', a non-null entity_type, and action in engine.SCORED_ACTIONS (the same allowlist live scoring uses — imported, never duplicated) are candidates, ordered occurred_at ascending, excluding any whose activity_id already appears in contribution_events.source_activity_id. confirm_record ("Confirm this record") logs with event_type='write', not 'audit', so it is never a replay candidate by construction — its reciprocal-guard/corroboration-target bookkeeping is inseparable from *live* state at verification time and would be wrong to recompute retroactively.
`extra` reconstruction (replay._build_extra), one case per action rather than a general framework, because each SCORED_ACTIONS handler needs different things:
upload_infrastructure_document/upload_customer_document(photo_or_document):parent_idfrom the document's own FK column (falling back to theafterpayload'sinfrastructure_id/customer_idkey if the document row is gone);image_dhashfrom the document'simage_dhashcolumn, or computed viasite_images.compute_image_dhashfrom the still-on-disk file and persisted back onto the row if the column is null and the file exists (mirrorsPOST /contributions/admin/backfill-dhash).update_infrastructure/update_customer/update_provider(record_update, including thecarrier->vendor.contactsub-path):entity_created_by/entity_created_atloaded from the live entity row (for the "creator fixing their own recent record" guard) — absent if the entity was hard-deleted since, which just disables that one guard rather than failing the row.create_site_link(link_create):site_entity_type/site_entity_idfrom the audit row's ownafter.infrastructure_id/after.customer_id(present sincesite_link_payloadhas always included them), falling back to the liveSiteLinkrow if not.document_metadata_update,record_create,finding_create,finding_update,comment_create: noextraneeded — their handlers don't take any.
Reputation. engine.recompute_reputation(session, user_id, now) rebuilds UserReputation from scratch off the ledger rather than applying one incremental delta (_update_reputation's live-scoring path is unchanged and still used by _write_event/reverse_event): lifetime_points/monthly_points are the net sum of contribution_events.points over awarded/reversed rows (a reversal's mirror row nets the pair to zero, matching recompute_reward_account's math), monthly_points restricted to the calendar month containing now; corroborated_count counts awarded verify.corroborated events; reversed_count counts only mirror rows (reversal_of IS NOT NULL) so it isn't double-counted against the flipped-status original row; streak_weeks is distinct ISO weeks with an awarded event in the trailing 12 weeks; level via level_for(...); evaluate_badges is re-run at the end. Called once per affected user after a real (non-dry-run) replay.
Reward exclusion. rewards.recompute_reward_account's ledger sum now excludes rows where evidence->>'replayed' = 'true', via or_(ContributionEvent.evidence.is_(None), ContributionEvent.evidence["replayed"].astext.is_distinct_from("true")) — deliberately *not* the more obvious evidence["replayed"].astext != "true", which is a real bug: Postgres's three-valued NULL logic makes that comparison unknown (excluded from the WHERE) whenever evidence is NULL *or* simply lacks a "replayed" key, which is true for every event predating this feature. IS DISTINCT FROM treats a missing/NULL value as distinct from 'true', which is what "not flagged as replayed" actually means. Net effect: with the default reward_eligible=False, replayed points count toward reputation/levels/the leaderboard immediately but never toward a Bonusly payout; reward_eligible=True skips setting the flag at all, so those events accrue normally.
Dry run. Each candidate row is scored inside its own session.begin_nested() SAVEPOINT (covering both _build_extra's document/dHash mutation and the score_activity call itself), then rolled back immediately after the primitive fields needed for the summary (rule_key, points, status) are read off the resulting ContributionEvent — reading them *before* the rollback matters, since SQLAlchemy expires/invalidates the ORM object once its row is gone. One correctness trap this surfaced: engine.load_rules seeds scoring_rules/badges with its own unconditional session.commit() the *first* time anything ever scores in a process; calling session.commit() from inside a SAVEPOINT ends that SAVEPOINT's transaction outright, so a later explicit nested.rollback() on it raises ResourceClosedError instead of quietly rolling back the row. replay_audit_history calls load_rules(session) once up front, before the loop and before any SAVEPOINT, so that one-time seed-and-commit never happens inside one.
Real run. Commits every 200 scored rows; after the loop, calls recompute_reputation and recompute_reward_account for every user with at least one newly-awarded event, then writes one audit-shaped UserActivityLog row directly (user_email="system", action replay_contribution_history, entity_type contribution_replay) summarizing the run — that action is deliberately never added to SCORED_ACTIONS, so it can never itself become a replay candidate (the same "allowlist, not denylist" guarantee the rest of the engine relies on), and it's inserted directly rather than through record_activity/score_activity so it's never scored by construction either.
Endpoint: POST /api/v1/contributions/admin/replay-history (recognition_admin), body {since: datetime, until?: datetime, reward_eligible?: bool=false, dry_run?: bool=true, limit?: int}, returns {rows_considered, scored, awarded, withheld, skipped: {reason: count}, per_user: [{email, display_name, awarded_points, awarded_events, withheld_events, by_rule}], dry_run, since, until} (per_user sorted by awarded_points descending).
Leaderboard. GET /contributions/leaderboard?period=month was already ledger-based (ContributionEvent.occurred_at >= start_of_month, not user_reputation.monthly_points) before this feature — no change needed there; a replayed event with a backdated occurred_at inside the current calendar month shows up on the current month's leaderboard exactly like a live one would.
Known limitations: confirm_record/verify.confirm/verify.corroborated history is never replayed (see above — inseparable from live verification state); import.commit scoring (score_import_commit, called directly by the imports router rather than through record_activity) has no corresponding user_activity_logs row shaped for this replay and isn't covered either. Both were already rare/recent enough at the point this shipped that neither gap blocks the backfill's purpose (records/photos/documents/findings/links/comments, the bulk of pre-launch history).
15. Implementation notes (Network records)
Shipped on branch feature/contributions-network-records. Extends the engine to score the most-edited network records — circuits, RF links, PtMP nodes, electrical services, subtended links — which weren't covered by Phase 1's FIELD_IMPORTANCE/SCORED_ACTIONS.
No new rule keys. record.create, record.fill, record.correct, photo.site, and document.site are reused as-is; only SCORED_ACTIONS (action → handler tag) and FIELD_IMPORTANCE (app/services/contributions/fields.py) gained entries. CATEGORY_BY_ENTITY was left untouched — none of the five new entity types are in it, so _category_for falls through to each rule's own default category (records for record.*), matching the design.
Field lists adopted (payload keys from each route's circuit_audit_payload/link_payload/node_payload/service_payload, not always raw model column names — verified against apps/api/app/api/v1/{circuits,rf_links,ptmp_nodes,electrical_services,infrastructure}.py):
circuit:carrier_circuit_id\*,service_type,bandwidth_mbps,status,mrc_usd\*,nrc_usd,ban_id,billing_account,monitoring_url\*,install_date,term_start,term_end\* (*= high value/x2).term_start/term_endmap tocircuit.contract.effective_date/.term_end_date— not plainCircuitcolumns — soGET /contributions/next-actionsspecial-cases them (eager-loadingcontract) the same way it already special-casescoordinates.protocol_handoffand the renewal/A-Z-endpoint fields exist on the model but aren't incircuit_audit_payload'safter, so per the "skip anything not inafter" rule they're not scored.rf_link:a_infrastructure_id\*,z_infrastructure_id\*,frequency_mhz\*,bandwidth_mbps\*,equipment_type\*,status,fcc_call_sign,polarization,install_date. RFLink has nomonitoring_urlcolumn at all (unlike Circuit/ElectricalService) — omitted here and from therf_link_missing_endpointmission's field keys. Only facility endpoints (a_infrastructure_id/z_infrastructure_id) are scored, not customer endpoints (a_customer_id/z_customer_id) — the common backhaul case.ptmp_node:sector_id,frequency_mhz\*,azimuth_deg\*,beamwidth_deg,height_agl_ft\*,capacity_mbps,rf_system_id.electrical_service:account_number\*,meter_number\*,service_type,delivery_location,average_monthly_cost_usd,monitoring_url,start_date.subtended_link:relationship_type,transport_type,circuit_id,rf_link_id,revenue_mrr_usd\*,cost_mrc_usd\*,effective_from,effective_to.revenue_mrr_usd/cost_mrc_usdare computed at read time (allocated_link_revenue/allocated_link_cost) and default to"0.00"rather than empty, so a real edit reads asrecord.correct("0.00" → a value), notrecord.fill.
Action → handler mapping (engine.SCORED_ACTIONS): create_circuit/create_rf_link/create_ptmp_node/create_electrical_service/create_subtended_link → record_create; the matching update_* → record_update (unchanged _score_record_update, since it already dispatches on FIELD_IMPORTANCE.get(entity_type) generically — no new code needed there beyond the fields.py entries). apply_circuit_agreement_extraction also rides record_update against entity_type="circuit"; its own before/after payload uses effective_date/term_end_date/initial_term_months/renewal_decision_deadline (contract fields named directly, not circuit_audit_payload's term_start/term_end spelling), so only the overlap (carrier_circuit_id, service_type, bandwidth_mbps, mrc_usd, nrc_usd) actually scores from that action — a deliberate simplification per the design's own "if the payload carries field changes, else skip."
Bare RF/electrical documents (upload_rf_link_document, upload_electrical_service_document → new bare_document handler tag, _score_bare_document): RFLinkDocument/ElectricalServiceDocument carry no content_type/gps/dhash columns, so image-vs-document is inferred from the original_filename extension, and there's no near-duplicate dHash check — dedupe instead reuses _write_event's existing dedupe-key-collision mechanism, keyed on a sha256 of the first 1MB of the stored file (falling back to the document id if the file is unreadable). Both upload routes now pass extra={"parent_id": ..., "stored_file_path": ...} to record_activity so the handler can read the file and roll the award up to the parent rf_link/electrical_service entity (same "ledger keys off the site, not the child row" pattern photo.site/document.site already use for infra/customer documents).
Agreement uploads (upload_infrastructure_agreement, upload_circuit_agreement → new agreement_document handler tag, _score_agreement_document): both attach a PDF directly to the parent record (no separate document table) and score as document.site (base 6 + comment bonus where a comment exists), deduped by content hash (falling back to the extraction id for circuit agreements, then a same-day entity bucket). Both routes now pass extra={"stored_file_path": ...} (circuit agreements also pass extraction_id).
Replay (replay.py): _RECORD_UPDATE_MODELS gained update_circuit/apply_circuit_agreement_extraction → Circuit, update_rf_link → RFLink, update_ptmp_node → PtmpNode, update_electrical_service → ElectricalService, update_subtended_link → SubtendedLink, for the creator-fix guard's entity_created_by/entity_created_at. A new _BARE_DOCUMENT_ACTIONS map plus _build_extra branches reconstruct parent_id/stored_file_path for the bare RF/electrical documents (from the still-live document row) and for the two agreement actions (from AgreementExtraction.stored_file_path for circuit agreements; from the asset's agreement_url reconstructed into an on-disk path under settings.storage_root/infrastructure-agreements/<infrastructure_id>/<stored_name> for infrastructure agreements) — so a historical replay's content-hash dedupe works the same as live scoring wherever the underlying file still exists on disk.
Missions (missions.py): two new gap types, circuit_missing_monitoring_url (Circuit rows with no monitoring_url) and rf_link_missing_endpoint (reuses GET /api/v1/alerts's "RF link is missing an A site or Z site/customer endpoint" condition verbatim, so the two never drift).
Next actions / verification chips: GET /contributions/next-actions now supports entity_type=circuit (circuits_view) and rf_link (rf_links_view); record_verifications' entity allowlist (app/api/v1/verifications.py::ENTITY_MODELS/VIEW_PERMISSION_FOR_ENTITY) gained the same two. VerificationChips with showNextActions is mounted on the circuit detail page (apps/web/app/circuits/[circuitId]/page.tsx). RF links have no detail page in the web app today (apps/web/app/rf-links is list-only) — nothing to mount there; revisit when/if one is added.
Deviation summary: (1) circuit's term_start/term_end required a small next-actions special-case (eager-loaded contract) rather than a bare getattr, since they aren't plain Circuit columns. (2) protocol_handoff and RF-link monitoring_url were dropped from the design's suggested field lists because they either aren't in the relevant audit payload or don't exist as a column at all. (3) apply_circuit_agreement_extraction only scores the subset of its fields that happen to share circuit_audit_payload's naming — its contract-date fields use different key names and are a documented no-op rather than double-mapped. (4) The actual action name is upload_circuit_agreement (not upload_circuit_agreement_document, as an initial read of the routes might suggest).
16. Implementation notes (Properties)
Shipped as work package A2 of the NetOS Properties plan (see docs/ plan for full context: a first-class Property entity that commercial customers link to, plus a reviewed address-scrub tool that proposes tenant groupings). A0 (the Property model, migration 0067, and properties_view/properties_edit permissions) landed first; A1 (services/properties.py, api/v1/properties.py, the customers.py integration) is a parallel work package and had not landed in this tree at the time A2 shipped, so everything below was built and tested against record_activity/score_activity directly rather than against /api/v1/properties endpoints that don't exist yet.
Action → handler mapping (engine.SCORED_ACTIONS): create_property/update_property ride the existing generic record_create/record_update handlers exactly like infrastructure/customer/circuit records do — no new handler code, just a FIELD_IMPORTANCE["property"] entry (fields.py) and CATEGORY_BY_ENTITY["property"] = "customers" (so property records show up on the leaderboard's Customers category, alongside customer records, rather than "records"). link_customer_property (entity_type "customer", the manual "Add tenant" action from a property's own page — one customer at a time) maps to a new handler tag, property_link → _score_property_link, modelled on _score_link_create. The bulk scrub-apply action (apply_property_group_proposal, and the per-customer link_property_customers/unlink_customer_property/link_infrastructure_property/unlink_infrastructure_property/delete_property/property_grouping_run rows the grouping service writes) are deliberately absent from SCORED_ACTIONS — an allowlist miss, not an oversight — so none of them can ever be scored by construction, matching the design's "the per-customer link audit rows it writes are not scored, so nobody gets 19 x 3 for one click."
`property.link_tenant` (engine._score_property_link): reads property_id from extra["property_id"], falling back to after["property_id"]; returns None (no ledger row at all) if there's no property id, or if before["property_id"] already equals it (a true no-op re-link, not expected from A1's service in practice but a cheap safety check). Dedupe key is property.link_tenant:customer:<customer_id>:<property_id> — no date component, so _write_event's existing dedupe-collision handling (any second write with the same key is converted to a withheld/duplicate row, zero points, per Phase 1) means a given customer+property pair pays exactly once, ever, not once per day. The per-user daily cap (default 30, admin-editable) is applied by hand rather than through the shared _apply_caps helper, because the design calls for a withheld_reason="daily_cap_reached" ledger row when the cap is hit — _apply_caps instead silently caps the *points* of an otherwise-awarded row to zero without changing its status, which is the right behavior for record.fill/record.correct/photo caps but not what was wanted here. extra["skip_scoring"] (set by the scrub tool per the plan) short-circuits before any of this, at _score_activity's existing top-level check — nothing property-specific was needed for that to work, and a test confirms it.
`property.group_apply` (engine.score_property_group_apply(session, *, principal, proposal_id, member_count)): called directly by the (not-yet-existing) grouping service's apply_proposal, the same way score_import_commit is called directly by the imports router — never through record_activity. Flat award: base(12) + min(max(member_count - 2, 0), 8) (Milkyway's 19 members → 12 + 8 = 20), dedupe key property.group_apply:property_group_proposal:<proposal_id> (so re-applying, or any future replay attempt, pays nothing — see below), per_user_per_day cap (60, via the shared _apply_caps helper this time, since there's no special withheld-reason requirement here), excluded for api_token/Integration:* principals exactly like every other direct-call scorer. Not added to SCORED_ACTIONS — there is no handler tag for it, so historical replay (which only ever considers UserActivityLog rows whose action is in that allowlist) can never re-score it even if an apply_property_group_proposal audit row exists. This is the literal mechanism behind the design's "neither action is replayable."
`fields.py`: FIELD_IMPORTANCE["property"] — coordinates/address_line_1/tenant_count at weight 2 (high value, doubles record.fill's base), property_manager/property_owner/manager_contact_phone/access_rules at weight 1. COMPOSITE_FIELDS["property"] = {"coordinates": ["latitude", "longitude"]}, matching the infrastructure/customer pattern exactly. RECOMMENDED_CREATE_FIELDS["property"] is derived automatically (the existing dict comprehension), giving record.create on a property a maximum bonus of min(8 recommended fields x 2, 20) = 16 on top of the 15-point base.
Insights: commercial_customers_at_shared_addresses_linked_to_property_pct (under completeness) — numerator is linked, denominator is every non-deleted commercial customer whose lower(trim(address_line_1)) + "|" + lower(trim(city)) key is shared by 2+ such customers (a same-address cluster a Property could group, whether or not one has been created yet). The query lives in missions.shared_address_commercial_customers(session) -> (all_ids, unlinked_ids) rather than being duplicated in contributions.py's insights endpoint — it returns both sets because the customers_missing_property gap type below needs exactly the unlinked_ids half of the same computation, and keeping the two in one place means they can never drift against each other. Note this measure counts *address clustering*, not properties: it stays meaningful even before any Property rows exist (i.e. before the address-scrub work packages ship), which is exactly the state this tree is in today.
Missions (missions.py GAP_TYPES): customers_missing_property (entity_type customer, rule_keys ("property.link_tenant",), no field-key filter, generator = shared_address_commercial_customers's unlinked half, capped at MAX_CANDIDATE_IDS, links to /customers?unassigned=true) and property_missing_tenant_count (entity_type property, rule_keys ("record.fill", "record.correct"), field key tenant_count only, generator = properties with tenant_count IS NULL, links to /properties). Both plug into find_active_mission_for_event/_mission_matches_event with zero new code there — the existing generic matching (rule key in the gap type's rule_keys, entity id in the frozen candidate set, field key filter only applied when the gap type declares one) already covers a property.link_tenant event on a customer entity and a record.fill event on a property entity without any special-casing.
`property.link_tenant` added to `CORROBORATABLE_RULES`: a manually-linked tenant is exactly the kind of "an independent signal that a record is correct" contribution §4 describes, so another user's later "Confirm this record" on that customer can corroborate it like any record.fill/vendor.contact/etc. award. property.group_apply was not added — it's a bulk administrative action on a proposal, not a single-record contribution someone would "confirm."
API surface, no new endpoints: NEXT_ACTION_MODELS["property"] = (Property, "property_id") and _RECORD_HREF_PREFIX["property"] = "/properties" (api/v1/contributions.py) plug property into the existing GET /contributions/next-actions and event record_href resolution for free. verifications.py gained ENTITY_MODELS["property"] = (Property, "property_id") and VIEW_PERMISSION_FOR_ENTITY["property"] = "properties_view", so POST /api/v1/verifications and GET /api/v1/verifications work for a property with no other code changes. comments.py gained "property" in COMMENTABLE_ENTITY_TYPES/_ENTITY_MODELS/_ENTITY_PK. search.py gained a property block (matching on name/nickname/address_line_1/city/state/postal_code/property_manager, badge "Property", href /properties/{id}) using the same row_payload/text_parts/score_for helpers every other entity block uses.
Tests (tests/test_contributions_properties.py): since A1's service/router don't exist yet in this tree, every scoring test drives the engine directly — record_activity(session, principal=..., action="create_property"/"update_property"/"link_customer_property", ...) with hand-built before/after payloads standing in for what A1's service will eventually send, and score_property_group_apply(...) called directly standing in for B3's grouping service. Property/Customer rows are created via the ORM. The API-surface tests (next-actions, /verifications, /search, /insights) exercise the real endpoints, since those don't depend on A1 at all.
Known limitation carried over from this being a parallel work package: nothing here has been exercised against A1's actual audit payload shapes (real property_payload/customer_payload field names) or against the property-grouping service's real apply_proposal call site — only against the *contract* both were specified to honor (entity_type/entity_id/before/after/extra shapes, and score_property_group_apply's keyword signature). Re-verify this section's examples once A1 and B3 land on main.
Phase B addition: property site media. A property can now have its own photos/documents, separate from its tenants' (PropertyDocument, app/api/v1/properties.py's document routes, migration 0070_property_site_media). upload_property_document is scored exactly like upload_infrastructure_document/upload_customer_document — added to SCORED_ACTIONS as "photo_or_document" and to DOCUMENT_MODELS["property_document"] = (PropertyDocument, "property_id", "property"), so it rides the existing _score_photo_or_document handler with no property-specific code: same photo.site/document.site/photo.sharepoint_ingest rules, same GPS/heading/caption/folder/first-on-site bonuses, same near-duplicate withholding, ledgered against the property itself (entity_type="property") exactly the way a customer's photo ledgers against the customer. update_property_document similarly rides document_metadata_update. A property is also now a third site_links/site_findings "site" kind (alongside infrastructure/customer) via app.services.site_targets.resolve_site_target's registry — create_site_link's site_entity_type extra is "property" when the link is on a property, same mechanism as the existing infrastructure/customer branches, and _build_extra in replay.py was extended with the matching property_id branches so historical replay covers property uploads and site links the same as any other site. property_document was also added to verifications.ENTITY_MODELS/VIEW_PERMISSION_FOR_ENTITY and comments.COMMENTABLE_ENTITY_TYPES for parity with infrastructure_document/customer_document. None of this touches insights or missions.
17. Implementation notes (Functional Teams)
Product decision (owner): a Team is a NetOS-managed functional group an admin assigns by hand -- deliberately separate from User.department (§10 Phase 2's Microsoft Graph sync, which keeps working unmodified). Eight teams are seeded on migration and in this order: Project Management, Field Operations, Break/Fix, Transmission Engineering, Executive, Engineering, Xperts, Billing. The leaderboard ranks both individuals and teams.
Model & migration (app.models.entities.Team, migration 0072_functional_teams): teams (team_id UUID pk, name unique via a case-insensitive ix_teams_name_lower index, slug unique, description, color, sort_order, is_active, full AuditMixin columns) and users.team_id (nullable FK, ON DELETE SET NULL, indexed). The eight seed rows are inserted with INSERT ... ON CONFLICT (slug) DO NOTHING, so re-running the migration (or re-seeding by hand) is a no-op, not a duplicate-key error.
API (app/api/v1/teams.py, prefix /api/v1/teams; full endpoint table in API & Integrations): GET is open to any authenticated user (app_access) since the roster is needed everywhere a team is shown or picked (leaderboard filter, team pills, authorization admin); POST/PATCH/DELETE require users_edit, the same permission that gates the rest of user administration in app/api/v1/ authz.py. DELETE is always a soft delete (is_active = false) -- members keep their team_id, so a deactivated team's historical leaderboard standing is never rewritten; there is no hard-delete path. Assigning a user happens through the existing user PATCH (PATCH /api/v1/authz/users/ {user_id} with {"team_id": "<uuid>" | null}), not a teams-specific route -- user_payload() and GET /api/v1/authz/me both gained a team: {team_id, name} | null field.
Leaderboard team ranking (GET /contributions/leaderboard): computed from the same per-user rows the individual leaderboard already aggregates (ContributionEvent joined to User, grouped by user_id), so the two views can never drift against each other. Each active team's row is {team_id, name, color, points, member_count, contributing_members, avg_points_per_member, events}:
points/eventsare the sum over every member's awarded events this
period (same period/category filters as the individual leaderboard).
member_countis every user currently assigned to that team via
users.team_id -- the full roster, whether or not they have any events this period. A team with zero current members is omitted from the ranked list entirely (an unused seeded team doesn't clutter the board).
contributing_membersis the subset ofmember_countwith at least one
event this period.
avg_points_per_memberispoints / member_count(one decimal place) --
deliberately divided by the *full roster*, not contributing_members, so a team's average reflects everyone the owner assigned to it, not just whoever happened to contribute.
Teams are sorted by points descending. A trailing {team_id: null, name: "Unassigned", ...} row aggregates users with events but no team assigned -- its member_count/avg_points_per_member are computed the same way, over every team-less user rather than a real roster. It is always last, regardless of its own point total, since it isn't a competing team. Example (from tests/test_functional_teams.py): three users on Team A scoring 10, 20, and 30 points, one user on Team B scoring 50, and one unassigned user scoring 5 → teams is [A: 60 pts / 3 members / avg 20.0, B: 50 pts / 1 member / avg 50.0, Unassigned: 5 pts].
A user assigned to a team that's since been deactivated still shows that team's name on their own leaderboard row and profile (same as a department value that no longer exists in Graph would) -- deactivation only removes the team from the *ranked* Teams view and from the assignment picker's active list, it never rewrites history. most_improved_team mirrors most_improved (largest month-over-month points delta), computed per team rather than per user, with no "under level 3" gate -- that's an individual reputation concept that doesn't map onto a team as a whole. team_id is also a new leaderboard query filter, narrowing the *individual* rows to one team; department/departments_available are kept for reference (GET /contributions/users/{id} and /me still show department alongside the new team), but the web leaderboard's own filter control was switched from department to team -- an owner-curated roster is more useful for narrowing a live leaderboard than a Graph attribute nobody manages day-to-day.
Monthly recognition email (app/services/contributions/ recognition_email.py): gained a Top Teams section (name, points, avg per member for the top 5 functional teams by points this month, member_count from the full roster like the leaderboard) placed above the existing department-based team-totals table, which is now labeled Department Totals for clarity now that "team" means something more specific. Both sections coexist -- nothing about the department sync path changed.
Web: lib/api/teams.ts (types + listTeams/createTeam/updateTeam/ deactivateTeam); lib/api/authz.ts gained AuthzUserTeam, team on AuthzUser/AuthzMe, and updateUserTeam. components/teams-admin.tsx -- mounted directly below AuthorizationAdmin on the Admin page's Users & Roles tab -- lists teams with live member counts and supports add, inline rename, a color picker, up/down reordering (swaps sort_order with the neighboring row), and deactivate/reactivate. authorization-admin.tsx's selected-user card gained a Team select (active teams plus "-- none --") that saves immediately via updateUserTeam with a small "Saved" line, and each row in the user list shows a team pill. leaderboards-workspace.tsx: Individuals view swapped its department text filter for a team select (same activeTeams roster) and gained a Team column; Teams view was rebuilt around the new row shape (Rank, Team with a color dot, Points, Members, Avg per member, Contributing members) with a "Rank by: Total | Avg per member" toggle that only re-sorts client-side (both numbers already come back in one response); a second side card shows the most-improved team beside the existing most-improved individual card. dashboard-leaderboard-card.tsx gained a compact "Top teams" line (top 3 by points, with color dots) below the top-5 people. /contributions/users/[userId] shows the person's team as a pill next to their name.
Tests (tests/test_functional_teams.py): migration seed (all eight slugs present with the specified sort_order, and re-running the same INSERT ... ON CONFLICT DO NOTHING shape a second time doesn't duplicate rows); team CRUD, soft delete (member keeps team_id after deactivation), and a Read-Only-role 403 on POST /teams (GET still succeeds); assigning a team via the authz PATCH; the A/B/unassigned leaderboard ranking example above, including the team_id filter narrowing individual rows; /me (both /api/v1/contributions/me and /api/v1/authz/me) reflecting the caller's team. The existing department-sync test (test_contributions_phase2.py) is untouched and still passes -- the two systems don't share any code.
18. Implementation notes (Leaderboard opt-out)
Admin request: let a user (e.g. an admin who doesn't want their own account cluttering the recognition leaderboard) hide themselves from every leaderboard/ranking surface, while still earning points, levels, badges, and rewards exactly as before.
Model & migration (User.exclude_from_leaderboard, migration 0077_leaderboard_opt_out): a plain boolean NOT NULL DEFAULT false on users. Nothing else changes shape -- reputation, badges, missions, and the reward ledger are computed exactly the same regardless of this flag; only *visibility* on ranking surfaces is affected.
Visibility helper (app.services.contributions.visibility): two functions, both used everywhere Integration:-role bot exclusion used to be inlined --
integration_user_ids(session): bot service accounts only. Used by
/contributions/insights' participation stats, where a human who merely opted out of the *leaderboard* should still count toward how many real people are contributing.
leaderboard_excluded_user_ids(session): bots plus every
exclude_from_leaderboard = true user. Used by GET /contributions/me (rank), GET /contributions/leaderboard (individual rows, team points/member_count/contributing_members, both most-improved calculations), and build_recognition_email (top contributors, top by category, verifier of the month, most improved, badges, department and team totals).
Self-view: an excluded user never gets a rank computed for *themselves* either (GET /contributions/me returns rank_this_month: null and excluded_from_leaderboard: true) -- the web client shows "Hidden from leaderboard" instead of a number or a dash.
Rewards: left untouched. The reward ledger (§7) is individual compensation, not a public ranking, and payouts are already globally disabled (reward_settings.payouts_enabled) -- there's nothing to gate here beyond what leaderboard visibility already covers.
API:
- Self-service:
PATCH /api/v1/contributions/mewith
{"exclude_from_leaderboard": true|false}, gated by contributions_view (granted to every human role) -- any user can toggle their own visibility.
- Admin:
exclude_from_leaderboardadded toUserPatch/
PATCH /api/v1/authz/users/{user_id} (users_edit), plus a new POST /api/v1/authz/users/bulk-leaderboard-visibility (same shape as bulk_user_roles) for the Users & roles bulk bar. Both paths audit via record_activity.
UI: components/contributions-workspace.tsx gained a "Show me on the leaderboard" checkbox next to the page header, and "Rank this month" reads "Hidden" (with a tooltip) instead of "#N"/"-" when excluded. components/user-roles-table.tsx gained a "Leaderboard" column (a clickable "On leaderboard" / "Hidden" pill per row) and a bulk "Hide from leaderboard" / "Show on leaderboard" pair next to the existing role/team bulk actions. components/authorization-admin.tsx's selected-user card gained a matching "Show on leaderboard" checkbox next to its Team select. Leaderboard views themselves (leaderboards-workspace.tsx, dashboard-leaderboard-card.tsx) needed no changes -- excluded users are simply absent from the rows the API already returns.
Tests (tests/test_leaderboard_opt_out.py): an excluded user earns points/levels/badges normally but is absent from the individual leaderboard, the team leaderboard's points/member_count/contributing_members, and most-improved (individual and team); their own /contributions/me shows rank_this_month: null and excluded_from_leaderboard: true; the admin PATCH and bulk endpoint set the flag with a 403 for non-users_edit principals; the self-service PATCH works for a Read-Only-role user and rejects setting *another* user's flag.
19. Implementation notes (Manual Bonusly awards, 2026-10-08)
Stephen asked to give someone a Bonusly bonus by hand from inside NetOS, with his own reason, starting from the leaderboard or from the person's contributions page, and counted against the budget.
Who and where: recognition admins (recognition_admin) see a Give Bonusly button on every person on the Leaderboards (podium and ranked list) and in the header of /contributions/users/{id}, never on their own row or page. The dialog takes points (presets 10/25/50/100 or any amount up to 1,000) and a reason (up to 500 characters), shows what's left of the month's budget, and says which account the bonus is sent from.
Sender and budget (changed 2026-10-17): a manual bonus is sent from the person giving it: giver_email is the signed-in admin's email. The connector's PAT is a Bonusly admin token, which may set giver_email. rewards._bonusly_giver_for looks them up first (BonuslyClient.allowance: GET /users?email= then GET /users/{id}, which carries giving_balance and earning_balance for an admin token). No account, can_give false, or an amount over their giving_balance is a 422 before anything is written. Manual bonuses come out of the giver's own allowance and no longer count against the company monthly budget, which is now for milestone payouts only (Stephen, 2026-10-17). Allowances are cached two minutes per email (rewards.bonusly_allowance) and refreshed after a bonus.
GET .../manual-awards/optionsand the award response carryallowance
(the caller's); GET /contributions/rewards/me carries bonusly (their giving and earning balances, gives_in_netos).
contributions/bonusly_nudges.py: Slack DMs (gatewayslack.post_dm)
to holders of recognition_award with a Bonusly account and something left to give. start on the first weekday of the month from 9:00 Indianapolis (allowance + last month's top 3), reminder on the first weekday of the month's last 7 days (what's left before it resets + top 3 so far). Once per month and kind (bonusly_allowance_dm rows in user_activity_logs); off with slack_announce.
The text below describes the original 2026-10-08 behaviour.
What gets sent: POST /bonuses from the configured giver (integration_connector_settings bonusly giver_email, else reward_settings.giver_email), reason +N @handle <reason>. When the reason has no #hashtag, reward_settings.hashtag (#dataquality) is added, since Bonusly asks every bonus for one.
Storage (migration 0113): a reward_redemptions row with kind="manual", reason, awarded_by, milestone_points=0. So manual awards:
- count against
reward_budget_monthslike milestone payouts (refused with
a 409 when the amount is more than the month has left);
- never touch the person's reward balance (
reward_accounts) or the
per-user milestone cap (_redemptions_this_month_count counts kind="milestone" only);
- don't need
payouts_enabled, which governs the automatic milestone
payouts; a manual award is an admin's explicit action. They do need the Bonusly connector enabled with a token.
Flow (rewards.give_manual_award): lock the month's budget row (FOR UPDATE; the nightly fulfill now locks it too), check the budget, insert the row as processing and reserve the amount, commit, call Bonusly, then commit delivered (with the Bonusly id and link) or failed (with Bonusly's message, and the reserved amount given back). The form sends a request_id, so a double click returns the first award instead of sending twice. A failed manual award isn't retried (the retry endpoint refuses it): give a new one. Each award is logged as manual_bonusly_award in the activity log. The person's page lists the bonuses they've received (admins only; bonuses is null for everyone else).
API: GET /contributions/rewards/manual-awards/options (connected, giver, hashtag, presets, budget) and POST /contributions/rewards/manual-awards (user_id, amount, reason, request_id). Tests: tests/test_manual_bonusly_awards.py.
20. Implementation notes (Awards in Slack, 2026-10-08)
Stephen asked for every award, weekly or manual, to be posted to the general Slack channel with a cool GIF or graphic.
What's posted (app/services/contributions/announcements.py), to reward_settings.slack_channel (#general by default) when reward_settings.slack_announce is on (the default):
- Manual Bonusly bonus: posted right after it's delivered, from the
manual-award endpoint. "@person just got +N Bonusly points from <giver>!" plus the reason as a quote. The Give Bonusly dialog has a "Post it to #general" checkbox (on by default) and shows the outcome. A repeated request (same request_id) never posts again.
- Milestone reward: posted once delivered (nightly payouts, or marked
delivered in batch mode) by announce_pending_milestones. The scheduler runs it every loop and catches up for 48 hours, so a failed post is tried again.
- Weekly champion ("Pacer of the Week"): last week's #1 on the
leaderboard (Monday-Sunday, Indianapolis time, people who hide from the leaderboard left out; the same rule as the badge), with the next two as "Close behind". Posted on Mondays from 9:00 Indianapolis time, once per week (a weekly_champion_announced row in user_activity_logs keyed by the week's Monday). Ties share the title ("Pacers of the Week").
How: the Xiber gateway's slack.post_message with Block Kit (section, image, context with a link to the NetOS leaderboard). The gateway can't upload files and NetOS is behind SSO, so the image is a public GIPHY GIF that Slack fetches itself. The lists (cheetahs and trophies for the champion; congratulations, confetti and trophies for bonuses) were looked at frame by frame on 2026-10-08: no people, brands, teams or hunting scenes. The same award always gets the same GIF. Winners are @-mentioned when slack.get_user finds them by email, else by exact name (Slack emails don't always match NetOS ones). User text is escaped for Slack mrkdwn. A failed post never fails or undoes the award; reward_redemptions.announced_at records posted awards (migration 0114).
Settings: Admin -> Recognition -> Rewards: "Announce awards in Slack" and the channel (a name gets its "#"; a channel ID like C1E0GD02H is kept). The gateway's Slack bot must be a member of the channel; it is in #general.
Tests: tests/test_slack_award_announcements.py (gateway always faked).
21. Implementation notes (Xiber's Bonusly rules, 2026-10-08)
Turning milestone payouts on, the first four payouts were refused by Bonusly (nothing was sent): Xiber's Bonusly requires at least one company hashtag in every bonus (hashtagging_mode = at_least_one_company_hashtag), and NetOS guessed the recipient's @username from the email (pkerr@ is @phil.kerr). Bonusly also allows only certain amounts per bonus (give_amounts, 10/20/30/40/50 at Xiber).
BonuslyClient.create_bonuslooks the recipient up (GET /users?email=)
and uses their real username. Someone not in Bonusly, or who can't receive, gets a clear error before any bonus is posted.
rewards.bonusly_company_rulesreadsGET /companies/show(company
hashtags, give amounts), cached an hour per token.
- Every reason (milestone and manual) gets a company hashtag when it has none
(manual_award_reason): the chosen one, else the settings' hashtag if it's a company hashtag, else the first company hashtag.
- Manual awards only accept the allowed amounts. The dialog offers them as
chips (no custom amount) plus a company-value hashtag picker.
- Settings: the hashtag must be a company hashtag and the reward per milestone
an allowed amount (both are selects in Admin -> Recognition -> Rewards). The milestone hashtag was set to #conscientious.
Xiber's company hashtags (2026-10-08): #grit #courageous #progress #mindful #conscientious #nimble #adapt #xealous #execute #energy #wintheday.
