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

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...


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

```sh
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
Canonical: https://docs.leumas.tech/p/engines/progression
