Docs
/
XP, badges, quests and cosmetics, all derived from one ledger

@leumas/progression

XP ledger, level curve, ranks, badges, quests, cosmetic ownership and the merged activity timeline. Everything derived from a ledger rather than stored — createProgressionRouter mounts it at...

@leumas/progression — XP, badges, quests and cosmetics, all derived from one ledger

The engine behind every number the Operator Deck shows. Total XP, level, rank, the next threshold, which badges are earned, which cosmetics are owned — all of it is derived on read from SUM(user_experience_events.amount) and never stored a second time. Part of the Operator Deck program — see ops/todos/dashboard-deck-roadmap.md (Contracts C1, C2, C3, C4, C8).

Thirteen modules in src/, no build step. Every dependency it does not own is injecteddb, connector, notify, automationEvents, entitlements, requireRole — because the engine must not reach into products/**, @leumas/features or router-kit. It may import @leumas/avatar/look and @leumas/avatar/slots (the React-free contract modules); the reverse is forbidden.

Exports (@leumas/progression, src/index.js)

ModuleExports
levels.jsMAX_LEVEL (100) · xpToReach(L) = 25 (L-1) L (L2 = 50, L10 = 2250, L100 = 247,500) · levelForXp · rankForLevel · RANKS (8 bands, recruitsovereign) · progressFor(xp){ xp, level, base, nextLevelAt, intoLevel, levelSpan, progress, rank }. Pure integer arithmetic, zero imports; also the @leumas/progression/levels subpath, the only other entry package.json declares
context.jsbuildProgressionContext(db, userId, { roles, isAdmin, can }) — the single gathering read, eight queries in one Promise.all · totalXpFor · loadBadgeDefs · loadCosmeticDefs(db, { tenantId, slot }), where a null tenantId is platform-wide and a set one is the white-label seam
badges.jsevaluateBadges · newlyUnlocked · currentValueFor · registerSignal / getSignal / listSignals
cosmetics.jsevaluateOwnership · evaluateOne · ownedCosmeticKeys · decorateCatalog · numericThreshold
award.jsawardXp(db, { userId, amount, source, reason, dedupeKey, awardedBy, metadata }, { notify, automationEvents }) · recomputeBadges (back-fills unlocks after a catalog change) · unlockedBadges · normalizeDedupeKey
quests.jsparseQuest · evaluateQuests · questProgress · settleQuests · setQuestSkipped · QUEST_SIGNALS · MAX_QUEST_XP (100)
look.jsreadLook · saveLook · resolveLookForRender · createLookResolver(db) — the last is what app.js hands createAvatarRouter, so @leumas/auth needs no dependency on this engine
activity.jsreadTimeline · readContinue · encodeCursor / decodeCursor · HISTORY_COLLECTIONS · CONTINUE_SOURCES
seed.jsseedProgression · seedQuests · BASELINE_BADGES / _TITLES / _EMBLEMS / _BANNERS / _FRAMES / _AURAS / _QUESTS
rules.jsPROGRESSION_RULES · RULE_IDS · SERVER_EMITTED_TRIGGERS · XP · readPath
actions.jscreateAwardXpAction · createRecordActivityAction · refuseReason · MAX_XP_PER_HOUR (2000) · _resetCeiling (a smoke-test hook, never called in normal operation)
router.jscreateProgressionRouter

badges.js and cosmetics.js are pure and synchronous over that one context object, which is what makes them testable with a plain object and no database. A new badge kind is one catalog row plus one function, never a schema change — custom requirements resolve through the signal registry rather than the enum — and both evaluators fail closed: an unknown signal never unlocks, and an unparseable unlockValue is Infinity, never 0.

readTimeline merges user_activitynotificationsrecord_history (mine, filtered to HISTORY_COLLECTIONS) on READ; copying them would be the second source of truth this engine exists to avoid. Its cursor keys on (timestamp, id), because a timestamp alone is not stable across three tables that can share a millisecond. readContinue covers configs and projects only — the two whose ownership the server can prove.

HTTP — /api/progression (mounted in products/leumas-api/src/app.js)

MethodPathBody / result
GET/methe derived view: xp, level, rank, nextLevelAt, progress, badges, counts
GET/badgesthe catalog with per-user current / target / progress / unlocked
GET/queststhe checklist — and it settles: newly-met quests are recorded and paid on read
POST/quests/:key/skip{ skipped? } → hidden server-side; 409 if the quest is already done
GET/cosmetics?slot= → the catalog with owned / locked / lockReason per row
GET/look{ look, signature, stored }
PUT/lookequip — 422 structure/namespace/slot, 413 size, 403 asset-owner or ownership
GET/activity?limit&cursor&kind{ items, nextCursor, unreadCount }
GET/continue?limit{ items }; 501 when no connector was injected
POST/award (admin)the audited door — a general "give any user any XP" primitive
POST/recompute (admin){ userId? } → back-fill badge unlocks
GET/user/:userId (admin)one named user's derived view; deliberately no list form

requireAuth is applied at the mount site, not inside the router — every route is owner-scoped and none is safe anonymously. Mount order is not load-bearing: createProgressionRouter takes no asset store, and the asset: check in look.js is a string comparison of the value's owner segment against the caller's id. isAppliance comes from caps.licenseRoutes && !caps.stripeWebhook rather than a new flag, so the appliance-vs-cloud fact keeps one source.

The ledger is the only truth

users.level and users.experience_points are dead columns — migrated, defaulting to 0, with zero runtime writers here. Anyone who "wires up XP" by writing users.experience_points has created a second source of truth that diverges from the ledger the moment one write fails, with no way afterwards to tell which is right. users.level does stay on the session payload (smoke-dashboard asserts it is a number), so do not remove it from getAuthUserState — just never write it. The same honesty rule runs to the end of the curve: at MAX_LEVEL, progressFor reports nextLevelAt: null and levelSpan: 0, so the meter draws no bar rather than a full one over an invented denominator.

awardXp is the only writer, and it never throws

Before it existed, recordPerformance in router-kit inserted straight into user_experience_events with no dedupe key, so a retried award banked twice. Every writer now funnels through awardXp — that same function in router-kit/src/sing-lib.js calls it with dedupeKey: 'sing:<performanceId>:xp'.

  • Idempotency is a database guarantee, not an application check. There is no

select-then-insert, which races. The insert carries ON CONFLICT DO NOTHING against the composite index declared in shared/packages/schemas/src/schema/gamification.js as uniqueIndex('xp_user_dedupe_unique').on(userId, dedupeKey), migrated in shared/packages/db/drizzle/0006_open_giant_man.sql.

  • COMPOSITE, and the easiest thing here to get wrong. A global unique index on dedupe_key

alone would let the first user to earn a shared key such as quest:first-avatar:complete claim it forever; every later user's award would come back duplicate with zero XP, silently and with no error. A single-user test passes cleanly against that bug, which is why pnpm smoke:progression asserts that two different users can each earn the same key.

  • Grammar <source>:<subject>:<verb>, lowercase, /^[a-z0-9._:-]{1,128}$/ — e.g.

site:<configId>:first-publish. normalizeDedupeKey returns null for anything else rather than throwing, and a null key means "may repeat freely": SQLite treats NULLs as DISTINCT in a unique index, so unkeyed awards are never deduped. That is deliberate.

  • It never throws on a duplicate. It returns { awarded: false, reason: 'duplicate' } with

the current derived totals filled in, because callers refresh the UI either way — likewise for 'no-user' and 'zero-amount'. Badge evaluation and the notify / automationEvents side effects are wrapped and logged: a failed notification must never roll back banked XP.

Typed tables and dynamic collections must never cross

Typed tables go through the drizzle handle from getDb() / createRepository() (@leumas/db); dynamic collections go through the injected connector. Crossing them does not error — connector.read('userExperienceEvents') silently runs CREATE TABLE IF NOT EXISTS dyn_userExperienceEvents and returns [], permanently wrong with nothing in the logs. Every table this engine touches is typed; the only connector callers are GET /continue, the quests catalog read, and seedQuests.

Quests are data; the XP rules are not

Quest definitions live in the quests dynamic collection so an operator can reword, reorder, re-price or delete them with no deploy — and quests is listed in the API's PLATFORM_COLLECTIONS (passed as adminOnlyCollections), because a quest row declares an XP award and /db is requireAuth only. Without that listing an ordinary member could POST a quest paying themselves a million XP. Defence in depth on top: parseQuest clamps xp to MAX_QUEST_XP, constrains the key to /^[a-z][a-z0-9-]{0,63}$/, and turns a NaN target into Infinity so a typo makes a quest uncompletable rather than auto-complete for everyone; settleQuests builds the quest:<key>:done dedupe key server-side, never from the request.

Three levers hide a quest and they are not interchangeable. entitlement is a per-user plan question the request answers through ctx.can. feature is a tenant flag the API genuinely cannot resolve, so it is passed through untouched for the client to apply (opt-out — absent means shown). requires: 'cloud' | 'local' keys on the deployment. Never gate on a capability key that @leumas/entitlements does not declare: a gate on a non-existent key fails closed for every real user while looking correct to an admin, who bypasses entitlement checks.

The XP rules went the other way, for a measured reason: rules is neither protected nor admin-only, armAll() arms every row regardless of owner, and every stored discriminator is forgeable — a non-admin created a rule and then nulled its owner with a PUT, because the adapter rewrites owner from the merged object. So PROGRESSION_RULES are module constants armed from code in app.js, and award-xp refuses any rule id outside RULE_IDS, any trigger outside SERVER_EMITTED_TRIGGERS, and any subject not read from a server-set payload field. A user-authored rule pointed at award-xp arms, fires and does nothing; pnpm smoke:progression-automation is the proof.

What the seeders will not do

Both run fire-and-forget at boot and are safe to re-run. seedProgression(db) upserts badges, titles, emblems, banners, frames and auras by key through upsertByUnique, so it never duplicates and never clobbers an operator's edits to a seeded row. seedQuests(adapter) goes through the connector and skips any key already present, so an operator's rewording and reordering survive — those keep the key. Deletion does not: have is built from the rows that exist, so a deleted key is absent and the next boot re-creates it. The supported way to retire a baseline quest is active: false, which parseQuest drops while leaving a row the seeder can still see. It throws a TypeError rather than no-op'ing when handed something without read / create, and retries SQLITE_BUSY across five attempts: at boot against single-writer SQLite a swallowed lock race meant no quests until the next restart, and an empty checklist reports percent: 100, which reads as "you have finished onboarding". Its one exception is a backfill — a baseline quest's feature is written onto a row that predates the field (the property absent, not empty), because without it every instance that had ever booted would keep an incoherent checklist forever while a fresh clone looked correct.

Verify

pnpm --filter @leumas/progression test   # node --test test/*.test.js — levels, catalog, quests
pnpm smoke:progression                   # ledger, composite dedupe, admin gate, badges  (3941)
pnpm smoke:progression-automation        # award-xp cannot be used to mint XP            (3944)
pnpm smoke:activity                      # the merge, the cursor walk, scoping           (3942)
pnpm smoke:operator                      # the look write path + the avatar ETag         (3940)
pnpm check:deck                          # bundle, asset and budget guards for the deck

The three test files run under a bare node --test with no transform, which is why the frame and aura catalogs are plain data in @leumas/avatar rather than inside a .jsx component: catalog.test.js is what stands between a one-character key typo and a cosmetic that is equippable and visibly does nothing. The dev DB is single-writer, so if pnpm dev holds data/leumas.db the smokes report database is locked — point LEUMAS_DB_URL at a copy.

Gating & surface

  • requireAuth at the mount; POST /award, POST /recompute and GET /user/:userId

additionally require the admin role. requireRole is injected and the inline isAdmin check is the fallback, not the belt — an award route that degrades to auth-only when a dependency is missing is exactly the hole that ships.

  • Studio: Accounts → Progression (/admin/progression, nav id progression, surface

ProgressionManager) and Accounts → Cosmetics (/admin/cosmetics, nav id cosmetics, surface CosmeticsManager) — both in products/leumas-studio/src/admin/nav.manifest.js.

  • The curve is also a domain adapter, shared/engines/adapters/domain/progression: six pure tools

(xpForLevel, levelForXp, progressFor, rankFor, levelTable, evaluateUnlock) over the same levels.js, so an agent's answer always matches what the user sees.

  • Two different "avatars". shared/engines/adapters/domain/avatar is the pre-existing

deterministic identicon generator — what a gen:<tool>/<seed> portrait resolves through, and where the pure look tools (composeLook, listCosmetics, renderCard, suggestLook) live. @leumas/avatar is the look/slot contract this engine imports.

Source shared/engines/progression/README.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt