@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 injected — db, 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)
| Module | Exports |
|---|---|
levels.js | MAX_LEVEL (100) · xpToReach(L) = 25 (L-1) L (L2 = 50, L10 = 2250, L100 = 247,500) · levelForXp · rankForLevel · RANKS (8 bands, recruit → sovereign) · 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.js | buildProgressionContext(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.js | evaluateBadges · newlyUnlocked · currentValueFor · registerSignal / getSignal / listSignals |
cosmetics.js | evaluateOwnership · evaluateOne · ownedCosmeticKeys · decorateCatalog · numericThreshold |
award.js | awardXp(db, { userId, amount, source, reason, dedupeKey, awardedBy, metadata }, { notify, automationEvents }) · recomputeBadges (back-fills unlocks after a catalog change) · unlockedBadges · normalizeDedupeKey |
quests.js | parseQuest · evaluateQuests · questProgress · settleQuests · setQuestSkipped · QUEST_SIGNALS · MAX_QUEST_XP (100) |
look.js | readLook · saveLook · resolveLookForRender · createLookResolver(db) — the last is what app.js hands createAvatarRouter, so @leumas/auth needs no dependency on this engine |
activity.js | readTimeline · readContinue · encodeCursor / decodeCursor · HISTORY_COLLECTIONS · CONTINUE_SOURCES |
seed.js | seedProgression · seedQuests · BASELINE_BADGES / _TITLES / _EMBLEMS / _BANNERS / _FRAMES / _AURAS / _QUESTS |
rules.js | PROGRESSION_RULES · RULE_IDS · SERVER_EMITTED_TRIGGERS · XP · readPath |
actions.js | createAwardXpAction · createRecordActivityAction · refuseReason · MAX_XP_PER_HOUR (2000) · _resetCeiling (a smoke-test hook, never called in normal operation) |
router.js | createProgressionRouter |
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_activity ∪ notifications ∪ record_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)
| Method | Path | Body / result |
|---|---|---|
| GET | /me | the derived view: xp, level, rank, nextLevelAt, progress, badges, counts |
| GET | /badges | the catalog with per-user current / target / progress / unlocked |
| GET | /quests | the 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 | /look | equip — 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
requireAuthat the mount;POST /award,POST /recomputeandGET /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 idprogression, 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/avataris 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.