Skip to content

Gamification Consumer

Gamification Consumer is a single-purpose RabbitMQ consumer (F4.GAM.02). It consumes the gamification.events topic exchange and is the sole writer of Strapi’s user-rank/gamification-event content-types (F4.GAM.01). It never trusts an XP value carried on an inbound message — XP is always recomputed server-side from src/xpRules.ts.

For the end-user–facing view of ranks, XP, and badges, see Ranks, XP & Achievements.

  • Port: 3800 (/health only — this service has no other HTTP surface)
  • Tech: TypeScript 5 (strict: true), Node.js ≥20, amqplib ^0.10, Axios, Pino logger (root CLAUDE.md’s pinned logger for new services)
  • RabbitMQ: consumes gamification.events (topic), routing keys xp.granted / badge.check / streak.tick / easter-egg.roll
  • Single-writer invariant: no other code may write to user-rank/gamification-event — both content-types are route/policy-locked to X-Service-Secret only (sencai.space’s src/policies/is-self-or-sencai-admin-user-rank.ts)
producers (cloud-instance lifecycle, runbook-execution, opsloop-consumer, …)
→ gamification.events (topic exchange, durable)
routing keys: xp.granted | badge.check | streak.tick | easter-egg.roll
→ gamification-consumer-queue (DLQ: gamification-consumer-queue.dlq, no TTL)
→ gamification-consumer (src/index.ts → src/messageHandler.ts)
→ sencai.space (X-Service-Secret): user-ranks, gamification-events,
badge-definitions, validate-user
→ re-publish: rank.up | badge.awarded | easter-egg.triggered
(onto the SAME exchange — never consumed by this service itself, to avoid a loop)
→ audit.events (audit.write) for rank-up/badge-award/easter-egg mutations

user-rank has no organisation relation — rank is portable across organisations by design (F4.GAM.01). This consumer never scopes by org; an inbound organisation_id is purely informational context for gamification-event.organisation.

Security model — what the consumer must never assume

Section titled “Security model — what the consumer must never assume”

gamification.events is a topic exchange with no cryptographic verification of the message’s origin (the same character as the platform.events fanout consumed by event-store-consumer). This drives several design decisions that must be respected in any future change:

  1. XP is never read from the payload. src/messageHandler.ts’s stripForgedXp() logs a warning and discards xp_granted if it arrives — the real value always comes from XP_MAP in src/xpRules.ts (computeXpForAction()). Do not remove this, and do not introduce a payload-value fallback “for testing convenience”.
  2. user_id is verified before every write. strapiClient.userExists() calls GET /api/user-ranks/validate-user/:id (a service-only endpoint shipped alongside this service). A nonexistent/forged user_idRejectMessageError → the message goes to the DLQ, never written.
  3. manual_admin_grant grants 0 XP automatically. This consumer has no way to verify the RabbitMQ publisher was genuinely a Sencai admin — honoring an arbitrary XP value for this action_type would reopen exactly the injection hole point 1 closes. A manual grant is handled directly in the Strapi admin UI by editing the user-rank record.
  4. The easter egg is stochastic with a hard per-user annual cap. src/easterEgg.ts’s rollEasterEgg() enforces the cap (EASTER_EGG_MAX_PER_USER_PER_YEAR) unconditionally, independent of how the roll probability is tuned.
  5. Idempotency = SHA-256(user_id:action_type:source_event_id). A duplicate delivery of the same message → Strapi returns 400 on the unique idempotency_key → the consumer treats this as a no-op, never as an error or a second XP grant.

action_type whitelist — must stay in lockstep with the Strapi enum

Section titled “action_type whitelist — must stay in lockstep with the Strapi enum”

src/xpRules.ts’s ACTION_TYPES must match sencai.space’s gamification-event content-type action_type enum exactly. Adding a new value requires changing both places — otherwise messages with the new type are silently rejected (DLQ) even though they should validly pass.

Automation-score classification (F4.GAM.07)

Section titled “Automation-score classification (F4.GAM.07)”

src/automationClassifier.ts classifies an xp.granted action as automated vs. manual and writes the verdict into gamification-event.metadata.is_automated at write time (handleXpGranted in messageHandler.ts). sencai.space’s GET /api/organisations/:id/automation-score (F4.GAM.07) only aggregates this field — it never derives the classification itself.

Only three action_type values are relevant to this metric (isAutomationScoreEligible()) — exactly the ones F4.GAM.03 producers attach an organisation field to:

  • provisioning_completed — reads metadata.source (mirroring cloud-instance.source): 'sencai' = automated (via the platform’s own cloud-connector automation, ADR-001), 'terraform' = imported from an existing tfstate (F3.IMPORT.01) — not this platform’s own automation. A missing value defaults to automated (the common 'sencai' case).
  • runbook_success — reads metadata.triggered_by (mirroring runbook-execution.triggered_by): 'alert' = automated, anything else/missing = manual.
  • incident_resolved — always manual. Verified directly against opsloop-consumer/src/services/*.ts — no automated flow ever sets an incident to resolved itself; it only triggers a runbook (captured separately as runbook_success) or opens a change-request.

Every other action_type (bookkeeping — login_streak, badge_awarded, easter_egg_triggered, automation_score_snapshot, manual_admin_grant) returns false from isAutomationScoreEligible()classifyAutomation() is never called for them, and their metadata is left untouched.

Audit — why it doesn’t vendor @sencai/audit

Section titled “Audit — why it doesn’t vendor @sencai/audit”

The three actions this service needs (gamification.rank.up, gamification.badge.awarded, gamification.easter_egg.triggered) are already in the canonical AuditAction union (packages/audit/src/types.ts, added by the FÁZE 4 consolidation wave). This service still uses its own small, loosely typed (action: string) publisher in src/audit.ts rather than vendoring @sencai/audit — the same pattern forum-connector chose for the identical situation. Vendoring is now unblocked, just not yet done.

Terminal window
PORT=3800
RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672
STRAPI_URL=http://backend:1337
SERVICE_SECRET= # shared with sencai.space's bare SERVICE_SECRET, not a dedicated GAMIFICATION_SERVICE_SECRET
EASTER_EGG_ANNUAL_QUALIFYING_ACTIONS=50000 # estimated qualifying actions/year, for probability tuning
EASTER_EGG_TARGET_TRIGGERS_PER_YEAR=12 # target platform-wide trigger count/year
EASTER_EGG_MAX_PER_USER_PER_YEAR=3 # hard per-user cap

Q: GET /api/badge-definitions returns 403 for a service-secret call

A: Fixed in sencai.space (F4.GAM.02) — global::hybrid-auth only checked ctx.state.user (a KC JWT or Strapi API token), never X-Service-Secret. find/findOne now have their own isAuthenticatedOrService() check in the controller instead of relying on the policy.

Q: Messages ending up in the DLQ

A: Unknown action_type, a nonexistent user_id, malformed JSON, or an unexpected Strapi/network error. Check gamification-consumer-queue.dlq (no TTL, manual recovery) via the RabbitMQ management UI (port 15672/15673 depending on profile).

Q: easter-egg.roll/streak.tick/badge.check have no producer in production

A: F4.GAM.03 (done 2026-07-05) only wired producers onto xp.granted (provisioning/runbook/incident) — no producer was ever in scope for these three routing keys. Test manually via rabbitmqadmin publish or the RabbitMQ management UI.

Q: Duplicate XP despite the idempotency guard

A: source_event_id is missing from the payload or differs between logically identical messages. The producer must send a stable source_event_id — this consumer cannot detect or correct that on its own side (the same trade-off as event-store-consumer’s timestamp-based fallback).

  • sencai.space — target storage (user-rank, gamification-event, badge-definition content-types, F4.GAM.01)
  • F4.GAM.03 producers — real XP-event emitters (cloud-instance lifecycle, runbook-execution, opsloop-consumer), wired onto xp.granted
  • F4.GAM.04/.05 — frontend rank widget + notifications, consuming rank.up/badge.awarded from gamification.events
  • Architecture — the wider service map