Gamification Operator Runbook
This runbook is paired with the PipelineStatusPanel on /settings/gamification (HR-admin view) and the GET /api/v1/gamification/operational-status endpoint behind it. It covers what each panel state means, what to check first, and how to recover.
Spec reference: 27A Gamification §4.1–§4.4 (see specs/27A_Gamification_Spec.md in the repo). Production-readiness loop is complete (B1 + H1 + H2 + H3 + M1 + M2 + M3 + M4 + L1 + L2 all shipped).
The gamification pipeline is a fan of four workers — they're mostly independent, unlike the Performance Hub which is a strict chain. The runbook is organized per-worker because the failure modes don't cascade.
Quick reference — service map
| Component | Where | Owns |
|---|---|---|
gamification-points-award | worker · handlers/gamification-points-award.ts | Writes one row per qualifying source event (QA / training / coaching / attendance / CSAT) into gamification_point_events. |
gamification-badge-eval | worker · handlers/gamification-badge-eval.ts | Inserts into gamification_agent_badges for every employee meeting an auto-badge trigger. Gated on perf-hub-snapshot completing today (for the consecutive_composite_above evaluator). |
gamification-leaderboard-refresh | worker · handlers/gamification-leaderboard-refresh.ts | UPSERTs render-ready rankings into gamification_leaderboard_cache per (tenant, scope, metric, window). |
gamification-challenge-progress | worker · handlers/gamification-challenge-progress.ts | Appends daily progress entries to gamification_challenges.progress_json and transitions ACTIVE→PENDING_CONFIRMATION on end_date. |
| Settings UI | web · /settings/gamification (HR-admin) | Renders the panel + engine config + active point-rule table. |
| Operational endpoint | api · routes/gamification/operational-status.ts | Reads worker_cron_completions and the gamification tables for the panel. |
Cron-chain cadences
| Queue | Cron | Cadence | Fresh band | Stale band |
|---|---|---|---|---|
gamification-points-award | 0 3 * * * | Nightly, 03:00 UTC (after perf-hub snapshot at 02:00) | ≤26h | ≤48h |
gamification-badge-eval | 30 3 * * * | Nightly, 03:30 UTC | ≤26h | ≤48h |
gamification-leaderboard-refresh | 0 */4 * * * | Every 4h | ≤5h | ≤8h |
gamification-challenge-progress | 0 4 * * * | Nightly, 04:00 UTC | ≤26h | ≤48h |
The leaderboard refresh has tighter bands than the nightly jobs — see classifyAge dispatch in operational-status.ts.
The status panel
The panel renders four blocks:
- Overall pill — worst of the four worker statuses. The badge color (green/amber/red/grey) tells you whether any worker is unhealthy without scanning the table.
- Coverage stats — active agents, engaged this month, active rules, active challenges. The juxtaposition is the diagnostic: rules>0 but engaged=0% means rules don't match any source data (a config issue); rules=0 means HR hasn't set the engine up yet.
- 14-day point-event volume sparkline — answers "is the engine still producing output?" Raw row counts, not coverage_pct. A sudden cliff is the strongest signal of a stuck cron — earlier than the worker freshness pill, which is age-based.
- Workers table — one row per worker with its freshness pill, last-run timestamp, and a per-worker metadata hint (awards inserted / cache rows refreshed / etc.).
Failure mode: a worker is stale or critical
stale (>fresh-band, ≤stale-band)
One missed run. Look at last_run_metadata first — the worker may have completed but produced zero output (e.g. zero matching source rows), which is a no-op, not a failure.
ssh serge@clear "docker compose -f /home/serge/frontline2/app/docker-compose.demo.yml logs worker --since 24h | grep gamification"
Common causes:
- Worker container restarted during the cron window. BullMQ should pick up the missed job within ~30 min via the repeat schedule; the next nightly run reads fresh sources and the marker advances.
- Database overrun. A slow
INSERT...SELECTon a tenant with millions of qa_evaluations / training_completions can push run-time past the next cron window. Checkidx_gamification_point_events_employee_history(and the indexes on the source tables —qa_evaluations.submitted_at,training_module_completions.completed_at).
If the next nightly run advances the marker, you're done — no further action.
critical (>stale-band)
Two or more missed runs in a row. This is the escalation point.
- Confirm the worker container is up:
ssh serge@clear "docker compose ps worker" - Tail the live logs for the gamification queues:
ssh serge@clear "docker compose logs worker -f | grep -E 'gamification-(points-award|badge-eval|leaderboard-refresh|challenge-progress)'" - Check
worker_cron_completionsto confirm what the API sees:SELECT queue_name, last_completed_at, last_run_metadata
FROM worker_cron_completions
WHERE queue_name LIKE 'gamification-%'
ORDER BY last_completed_at DESC; - If logs show the handler completed but
worker_cron_completionswasn't updated, themarkJobCompletedwrite failed — check pool connectivity. The helper logscron_chain: markJobCompleted failedon its own.
never_ran
The queue has no row in worker_cron_completions at all. Either:
- Fresh tenant install that hasn't hit its first nightly run yet. Expected for the first 24h; the next 03:00 UTC tick will resolve it.
- Queue registration regression. Check
app/services/worker/src/worker.tsfor theregisterScheduledJobcall for that queue. Without registration, BullMQ never schedules it.
Failure mode: badge-eval blocked by upstream gate
The badge evaluator calls requireUpstream("perf-hub-snapshot", "gamification-badge-eval") at the top. When the gate trips you'll see in the worker log:
cron_chain: skipping downstream — upstream has not completed for today
The consecutive_composite_above evaluator reads perf_hub_snapshots; without a same-day snapshot the evaluator would idempotently inscribe badge awards against yesterday's data. The skip is intentional — the next nightly run after perf-hub fires catches up.
Investigate the upstream: the perf-hub runbook covers perf-hub-snapshot stale/critical states.
Failure mode: engaged_this_month is 0% but active_rules > 0
This is the most common "broken but not throwing" failure mode. Workers are running, markers are fresh, but no agents are accruing points.
Diagnosis
-- For a tenant, show how many rule evaluations each event_type SHOULD produce vs. did.
WITH rules AS (
SELECT id, event_type, threshold_config_json
FROM gamification_point_rules
WHERE tenant_id = $1 AND active = true
),
events AS (
SELECT event_type, COUNT(*) AS awards
FROM gamification_point_events
WHERE tenant_id = $1 AND awarded_at >= date_trunc('month', now())
GROUP BY event_type
)
SELECT r.event_type, r.threshold_config_json, COALESCE(e.awards, 0) AS awards_this_month
FROM rules r LEFT JOIN events e USING (event_type);
Look for:
- Threshold mis-set.
min_score_pct: 100for QA — no agent ever scores exactly 100. - No matching source data. A
coaching_acknowledgedrule with zerocoaching_session_acknowledgementsrows means coaching hasn't been used yet on this tenant. - LOB scope filters everyone out. A rule scoped to a LOB with no active assigned employees. Cross-check via:
SELECT cal.name, COUNT(*) AS active_agents
FROM client_account_lobs cal
LEFT JOIN employee_profile_history eph
ON eph.client_lob_id = cal.id
AND eph.tenant_id = cal.tenant_id
AND eph.effective_to IS NULL
LEFT JOIN employees e
ON e.id = eph.employee_id AND e.status = 'active'
WHERE cal.tenant_id = $1
GROUP BY cal.name;
Failure mode: leaderboard cache is stale despite refresh runs
The refresh worker runs every 4h but only writes a cache row when a gamification_configs row exists for the tenant + LOB. Three checks:
-- 1. Does the tenant have any active configs at all?
SELECT client_lob_id, leaderboard_scope, leaderboard_metric, active
FROM gamification_configs
WHERE tenant_id = $1 AND active = true;
-- 2. For configs with scope=CLIENT, is the LOB linked to a client_account?
SELECT cal.client_account_id
FROM gamification_configs gc
JOIN client_account_lobs cal ON cal.id = gc.client_lob_id
WHERE gc.tenant_id = $1 AND gc.leaderboard_scope = 'CLIENT';
-- 3. Are there any opted-out agents skewing the cohort? (Not a bug,
-- but a "where did they go" answer.)
SELECT COUNT(*) FROM gamification_agent_prefs
WHERE tenant_id = $1 AND leaderboard_opt_out_at IS NOT NULL;
The refresh worker log.warn's tenant-id when its per-config refresh throws — search for that in the worker logs.
Failure mode: anomalies fired
The status panel surfaces anomalies in an amber card immediately below the coverage stats. HR admins also receive an aggregated in-app notification (gamification.anomaly_detected, routes through the performance category) when anomalies fire — one notification per HR admin per tenant per run, deterministically deduped so a worker re-run is a no-op.
Two anomaly kinds ship today (the points-award worker detects both):
zero_awards_with_active_rules
The tenant has ≥ 1 active rule but the points-award worker wrote zero gamification_point_events rows for today. This is the more actionable of the two — it tells the operator "rules ran, nothing matched" rather than "we used to be producing output, now we're not."
Common causes:
- Threshold mis-set on a recently-edited rule. Someone bumped
min_score_pctfrom 80 to 100 in/settings/gamification; no QA evaluation will hit exactly 100. - Source data outage. No QA evaluations submitted today (e.g., QA module misconfigured), no training completed, no coaching acknowledged.
- LOB scope filters everyone out. A rule scoped to a LOB that has no active assigned employees this period.
Diagnosis: Use the "engaged_this_month is 0% but active_rules > 0" diagnostic block above.
points_event_drop
Today's gamification_point_events row count fell more than 30% below the LATERAL-picked prior date (the most recent date in the last 14 days with non-zero awards). The LATERAL pick is important: a naive "yesterday vs today" would read every Monday as a 100% drop vs Sunday for tenants with no weekend QA traffic.
Common causes:
- Source-data regression. QA submissions volume dropped because a scorecard was retired without notice. Training module assignments paused for a holiday.
- One rule deactivated. Someone toggled
active=falseon the highest-volume rule (oftencoaching_acknowledgedortraining_completed_on_time). - Workforce volume shift. Half the agents are on a leave window; events follow proportionally.
Diagnosis: Compare per-event-type counts day-over-day to see which rule lost output:
SELECT event_type, awarded_at::date AS day, COUNT(*) AS awards
FROM gamification_point_events
WHERE tenant_id = $1
AND awarded_at::date >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY event_type, day
ORDER BY day DESC, awards DESC;
If one event_type cliffs to 0 on a specific day, look at the corresponding source table on that day.
Why anomalies don't abort the run
The points-award worker writes idempotently — re-running on a clean source-data day overwrites yesterday's findings. Aborting on an anomaly would only deepen the data gap. Detection is observability only.
Recovery: extended-lookback backfill
For most catch-up scenarios (a single missed nightly run, a few-day worker outage), the next normal nightly tick handles it — the worker's 14-day lookback covers the window automatically via its idempotent UNIQUE.
For longer scenarios, use pnpm backfill:gamification-points. Two real cases:
A tenant just enabled gamification mid-month
The nightly worker only credits the last 14 days of source events. To extend back to the start of the month (or up to 90 days):
ssh serge@clear "cd /home/serge/frontline2/app && \
DATABASE_URL=$DATABASE_URL pnpm backfill:gamification-points --lookback-days 60"
The script invokes the same handler the cron uses, with lookbackDays overridden. Idempotent — re-running on the same source window is a no-op.
After a longer outage
pnpm backfill:gamification-points --lookback-days 30
By default the script suppresses anomaly notifications so a multi-day replay doesn't fan out one "anomaly" notification per day to HR. The anomalies are STILL detected and persisted to worker_cron_completions.last_run_metadata.anomalies — only the L2 in-app fan-out is muted. Pass --alerts if you actually want HR to see them:
pnpm backfill:gamification-points --lookback-days 30 --alerts
Safety cap
The script caps --lookback-days at 90. Beyond that, ROLLING_90D-mode tenants would see events whose expiry_at is in the past — they'd land in the DB but never count toward an agent's balance.
One-off cron tick for the other workers
For badge-eval, leaderboard-refresh, or challenge-progress (which have no backfill script — their work is current-state-only), trigger a one-off run by enqueueing a job directly:
ssh serge@clear "docker compose exec api node -e \"
const { Queue } = require('bullmq');
const q = new Queue('gamification-badge-eval', {
connection: { host: 'redis', port: 6379 }
});
q.add('manual-recovery', {}).then(() => process.exit(0));
\""
Substitute the queue name for the worker you want to re-run. The handler is idempotent across all four workers (UNIQUE constraints on output tables), so re-running over the same day is safe.
Diagnostic queries cheat sheet
Top 10 point-event-earning agents this month
SELECT employee_id, COUNT(*) AS events, SUM(points_awarded) AS points
FROM gamification_point_events
WHERE tenant_id = $1
AND awarded_at >= date_trunc('month', now())
GROUP BY employee_id
ORDER BY points DESC
LIMIT 10;
Per-event-type award rate over last 14 days
SELECT event_type, awarded_at::date AS day, COUNT(*) AS awards
FROM gamification_point_events
WHERE tenant_id = $1
AND awarded_at >= CURRENT_DATE - INTERVAL '14 days'
GROUP BY event_type, day
ORDER BY day DESC, awards DESC;
Active challenges + days remaining
SELECT id, name, metric, target_value, start_date, end_date,
(end_date - CURRENT_DATE) AS days_remaining
FROM gamification_challenges
WHERE tenant_id = $1 AND status = 'ACTIVE'
ORDER BY end_date;
Show the chain status all at once
SELECT queue_name,
last_completed_at,
(now() - last_completed_at) AS age,
last_run_metadata
FROM worker_cron_completions
WHERE queue_name LIKE 'gamification-%'
ORDER BY queue_name;
Escalation
If a worker is critical for >72h or the engaged_this_month coverage is anomalously low compared to history (the sparkline cliffs), page the on-call. The audit trail is in audit_events (search resource_type = 'gamification_*') and gamification_point_events.awarded_at — both are append-only.
For data-correctness incidents (wrong points awarded, double-credit, missing badges), do not delete rows. The UNIQUE constraints make the worker idempotent; deletions just re-create the wrong state on the next run. Open a spec amendment instead.