Gamification Consumer
Gamification Consumer
Section titled “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.
Key Facts
Section titled “Key Facts”- Port: 3800 (
/healthonly — this service has no other HTTP surface) - Tech: TypeScript 5 (
strict: true), Node.js ≥20,amqplib^0.10, Axios, Pino logger (rootCLAUDE.md’s pinned logger for new services) - RabbitMQ: consumes
gamification.events(topic), routing keysxp.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 toX-Service-Secretonly (sencai.space’ssrc/policies/is-self-or-sencai-admin-user-rank.ts)
Architecture
Section titled “Architecture”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 mutationsuser-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:
- XP is never read from the payload.
src/messageHandler.ts’sstripForgedXp()logs a warning and discardsxp_grantedif it arrives — the real value always comes fromXP_MAPinsrc/xpRules.ts(computeXpForAction()). Do not remove this, and do not introduce a payload-value fallback “for testing convenience”. user_idis verified before every write.strapiClient.userExists()callsGET /api/user-ranks/validate-user/:id(a service-only endpoint shipped alongside this service). A nonexistent/forgeduser_id→RejectMessageError→ the message goes to the DLQ, never written.manual_admin_grantgrants 0 XP automatically. This consumer has no way to verify the RabbitMQ publisher was genuinely a Sencai admin — honoring an arbitrary XP value for thisaction_typewould reopen exactly the injection hole point 1 closes. A manual grant is handled directly in the Strapi admin UI by editing theuser-rankrecord.- The easter egg is stochastic with a hard per-user annual cap.
src/easterEgg.ts’srollEasterEgg()enforces the cap (EASTER_EGG_MAX_PER_USER_PER_YEAR) unconditionally, independent of how the roll probability is tuned. - 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— readsmetadata.source(mirroringcloud-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— readsmetadata.triggered_by(mirroringrunbook-execution.triggered_by):'alert'= automated, anything else/missing = manual.incident_resolved— always manual. Verified directly againstopsloop-consumer/src/services/*.ts— no automated flow ever sets an incident toresolveditself; it only triggers a runbook (captured separately asrunbook_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.
Configuration
Section titled “Configuration”PORT=3800RABBITMQ_URL=amqp://admin:admin@sencai-mq:5672STRAPI_URL=http://backend:1337SERVICE_SECRET= # shared with sencai.space's bare SERVICE_SECRET, not a dedicated GAMIFICATION_SERVICE_SECRETEASTER_EGG_ANNUAL_QUALIFYING_ACTIONS=50000 # estimated qualifying actions/year, for probability tuningEASTER_EGG_TARGET_TRIGGERS_PER_YEAR=12 # target platform-wide trigger count/yearEASTER_EGG_MAX_PER_USER_PER_YEAR=3 # hard per-user capTroubleshooting
Section titled “Troubleshooting”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).
Related services
Section titled “Related services”- sencai.space — target storage (
user-rank,gamification-event,badge-definitioncontent-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.awardedfromgamification.events - Architecture — the wider service map