{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/progression",
  "slug": "engines/progression",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/progression — XP, badges, quests and cosmetics, all derived from one ledger",
  "name": "@leumas/progression",
  "eyebrow": "XP, badges, quests and cosmetics, all derived from one ledger",
  "chip": null,
  "summary": "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...",
  "keywords": [
    "progression",
    "createprogressionrouter",
    "quests",
    "badges",
    "awardxp writer throws",
    "progression api",
    "leumas progression",
    "how to use progression"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# @leumas/progression — XP, badges, quests and cosmetics, all derived from one ledger\n\nThe engine behind every number the Operator Deck shows. Total XP, level, rank, the next\nthreshold, which badges are earned, which cosmetics are owned — all of it is **derived on read**\nfrom `SUM(user_experience_events.amount)` and never stored a second time.\nPart of the Operator Deck program — see `ops/todos/dashboard-deck-roadmap.md` (Contracts **C1**,\n**C2**, **C3**, **C4**, **C8**).\n\nThirteen modules in `src/`, no build step. Every dependency it does not own is **injected** —\n`db`, `connector`, `notify`, `automationEvents`, `entitlements`, `requireRole` — because the\nengine must not reach into `products/**`, `@leumas/features` or router-kit. It may import\n`@leumas/avatar/look` and `@leumas/avatar/slots` (the React-free contract modules); the reverse is\nforbidden.\n\n## Exports (`@leumas/progression`, `src/index.js`)\n\n| Module | Exports |\n|---|---|\n| `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 |\n| `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 |\n| `badges.js` | `evaluateBadges` · `newlyUnlocked` · `currentValueFor` · `registerSignal` / `getSignal` / `listSignals` |\n| `cosmetics.js` | `evaluateOwnership` · `evaluateOne` · `ownedCosmeticKeys` · `decorateCatalog` · `numericThreshold` |\n| `award.js` | `awardXp(db, { userId, amount, source, reason, dedupeKey, awardedBy, metadata }, { notify, automationEvents })` · `recomputeBadges` (back-fills unlocks after a catalog change) · `unlockedBadges` · `normalizeDedupeKey` |\n| `quests.js` | `parseQuest` · `evaluateQuests` · `questProgress` · `settleQuests` · `setQuestSkipped` · `QUEST_SIGNALS` · `MAX_QUEST_XP` (100) |\n| `look.js` | `readLook` · `saveLook` · `resolveLookForRender` · `createLookResolver(db)` — the last is what `app.js` hands `createAvatarRouter`, so `@leumas/auth` needs no dependency on this engine |\n| `activity.js` | `readTimeline` · `readContinue` · `encodeCursor` / `decodeCursor` · `HISTORY_COLLECTIONS` · `CONTINUE_SOURCES` |\n| `seed.js` | `seedProgression` · `seedQuests` · `BASELINE_BADGES` / `_TITLES` / `_EMBLEMS` / `_BANNERS` / `_FRAMES` / `_AURAS` / `_QUESTS` |\n| `rules.js` | `PROGRESSION_RULES` · `RULE_IDS` · `SERVER_EMITTED_TRIGGERS` · `XP` · `readPath` |\n| `actions.js` | `createAwardXpAction` · `createRecordActivityAction` · `refuseReason` · `MAX_XP_PER_HOUR` (2000) · `_resetCeiling` (a smoke-test hook, never called in normal operation) |\n| `router.js` | `createProgressionRouter` |\n\n`badges.js` and `cosmetics.js` are **pure and synchronous** over that one context\nobject, which is what makes them testable with a plain object and no database. A new badge kind is\n**one catalog row plus one function**, never a schema change — `custom` requirements resolve\nthrough the signal registry rather than the enum — and both evaluators fail closed: an unknown\nsignal never unlocks, and an unparseable `unlockValue` is `Infinity`, never `0`.\n\n`readTimeline` merges `user_activity` ∪ `notifications` ∪ `record_history` (mine, filtered to\n`HISTORY_COLLECTIONS`) on READ; copying them would be the second source of truth this engine\nexists to avoid. Its cursor keys on `(timestamp, id)`, because a timestamp alone is not stable\nacross three tables that can share a millisecond. `readContinue` covers `configs` and `projects`\nonly — the two whose ownership the server can prove.\n\n## HTTP — `/api/progression` (mounted in `products/leumas-api/src/app.js`)\n\n| Method | Path | Body / result |\n|---|---|---|\n| GET | `/me` | the derived view: `xp`, `level`, `rank`, `nextLevelAt`, `progress`, `badges`, `counts` |\n| GET | `/badges` | the catalog with per-user `current` / `target` / `progress` / `unlocked` |\n| GET | `/quests` | the checklist — **and it settles**: newly-met quests are recorded and paid on read |\n| POST | `/quests/:key/skip` | `{ skipped? }` → hidden server-side; `409` if the quest is already done |\n| GET | `/cosmetics` | `?slot=` → the catalog with `owned` / `locked` / `lockReason` per row |\n| GET | `/look` | `{ look, signature, stored }` |\n| PUT | `/look` | equip — `422` structure/namespace/slot, `413` size, `403` asset-owner or ownership |\n| GET | `/activity` | `?limit&cursor&kind` → `{ items, nextCursor, unreadCount }` |\n| GET | `/continue` | `?limit` → `{ items }`; **`501`** when no `connector` was injected |\n| POST | `/award` **(admin)** | the audited door — a general \"give any user any XP\" primitive |\n| POST | `/recompute` **(admin)** | `{ userId? }` → back-fill badge unlocks |\n| GET | `/user/:userId` **(admin)** | one named user's derived view; deliberately no list form |\n\n`requireAuth` is applied **at the mount site**, not inside the router — every route is\nowner-scoped and none is safe anonymously. Mount order is not load-bearing:\n`createProgressionRouter` takes no asset store, and the `asset:` check in `look.js` is a string\ncomparison of the value's owner segment against the caller's id. `isAppliance` comes from\n`caps.licenseRoutes && !caps.stripeWebhook` rather than a new flag, so the appliance-vs-cloud fact\nkeeps one source.\n\n## The ledger is the only truth\n\n`users.level` and `users.experience_points` are **dead columns** — migrated, defaulting to `0`,\nwith zero runtime writers here. Anyone who \"wires up XP\" by writing `users.experience_points` has\ncreated a second source of truth that diverges from the ledger the moment one write fails, with no\nway afterwards to tell which is right. `users.level` does stay on the session payload\n(`smoke-dashboard` asserts it is a number), so do not remove it from `getAuthUserState` — just\nnever write it. The same honesty rule runs to the end of the curve: at `MAX_LEVEL`, `progressFor`\nreports `nextLevelAt: null` and `levelSpan: 0`, so the meter draws no bar rather than a full one\nover an invented denominator.\n\n## awardXp is the only writer, and it never throws\n\nBefore it existed, `recordPerformance` in router-kit inserted straight into\n`user_experience_events` with no dedupe key, so a retried award banked twice. Every writer now\nfunnels through `awardXp` — that same function in `router-kit/src/sing-lib.js` calls it with\n`dedupeKey: 'sing:<performanceId>:xp'`.\n\n- **Idempotency is a database guarantee, not an application check.** There is no\n  select-then-insert, which races. The insert carries `ON CONFLICT DO NOTHING` against the\n  composite index declared in `shared/packages/schemas/src/schema/gamification.js` as\n  `uniqueIndex('xp_user_dedupe_unique').on(userId, dedupeKey)`, migrated in\n  `shared/packages/db/drizzle/0006_open_giant_man.sql`.\n- **COMPOSITE, and the easiest thing here to get wrong.** A global unique index on `dedupe_key`\n  alone would let the first user to earn a shared key such as `quest:first-avatar:complete` claim\n  it forever; every later user's award would come back `duplicate` with zero XP, silently and with\n  no error. A single-user test passes cleanly against that bug, which is why\n  `pnpm smoke:progression` asserts that **two different users** can each earn the same key.\n- **Grammar** `<source>:<subject>:<verb>`, lowercase, `/^[a-z0-9._:-]{1,128}$/` — e.g.\n  `site:<configId>:first-publish`. `normalizeDedupeKey` returns `null` for anything else rather\n  than throwing, and a `null` key means \"may repeat freely\": SQLite treats NULLs as DISTINCT in a\n  unique index, so unkeyed awards are never deduped. That is deliberate.\n- **It never throws on a duplicate.** It returns `{ awarded: false, reason: 'duplicate' }` *with*\n  the current derived totals filled in, because callers refresh the UI either way — likewise for\n  `'no-user'` and `'zero-amount'`. Badge evaluation and the `notify` / `automationEvents` side\n  effects are wrapped and logged: a failed notification must never roll back banked XP.\n\n## Typed tables and dynamic collections must never cross\n\nTyped tables go through the drizzle handle from `getDb()` / `createRepository()` (`@leumas/db`);\ndynamic collections go through the injected `connector`. Crossing them does not error —\n`connector.read('userExperienceEvents')` silently runs `CREATE TABLE IF NOT EXISTS\ndyn_userExperienceEvents` and returns `[]`, permanently wrong with nothing in the logs. Every\ntable this engine touches is typed; the only `connector` callers are `GET /continue`, the `quests`\ncatalog read, and `seedQuests`.\n\n## Quests are data; the XP rules are not\n\nQuest definitions live in the `quests` dynamic collection so an operator can reword, reorder,\nre-price or delete them with no deploy — and `quests` is listed in the API's\n`PLATFORM_COLLECTIONS` (passed as `adminOnlyCollections`), because a quest row declares an XP\naward and `/db` is `requireAuth` only. Without that listing an ordinary member could POST a quest\npaying themselves a million XP. Defence in depth on top: `parseQuest` clamps `xp` to\n`MAX_QUEST_XP`, constrains the key to `/^[a-z][a-z0-9-]{0,63}$/`, and turns a NaN target into\n`Infinity` so a typo makes a quest uncompletable rather than auto-complete for everyone;\n`settleQuests` builds the `quest:<key>:done` dedupe key server-side, never from the request.\n\nThree levers hide a quest and they are not interchangeable. `entitlement` is a per-user plan\nquestion the request answers through `ctx.can`. `feature` is a **tenant** flag the API genuinely\ncannot resolve, so it is passed through untouched for the client to apply (opt-out — absent means\nshown). `requires: 'cloud' | 'local'` keys on the deployment. Never gate on a capability key that\n`@leumas/entitlements` does not declare: a gate on a non-existent key fails closed for every real\nuser while looking correct to an admin, who bypasses entitlement checks.\n\nThe **XP rules** went the other way, for a measured reason: `rules` is neither protected nor\nadmin-only, `armAll()` arms every row regardless of owner, and every stored discriminator is\nforgeable — a non-admin created a rule and then nulled its `owner` with a PUT, because the adapter\nrewrites `owner` from the merged object. So `PROGRESSION_RULES` are module constants armed from\ncode in `app.js`, and `award-xp` refuses any rule id outside `RULE_IDS`, any trigger outside\n`SERVER_EMITTED_TRIGGERS`, and any subject not read from a server-set payload field. A\nuser-authored rule pointed at `award-xp` arms, fires and does nothing;\n`pnpm smoke:progression-automation` is the proof.\n\n## What the seeders will not do\n\nBoth run fire-and-forget at boot and are safe to re-run. `seedProgression(db)` upserts badges,\ntitles, emblems, banners, frames and auras by `key` through `upsertByUnique`, so it never\nduplicates and **never clobbers an operator's edits** to a seeded row. `seedQuests(adapter)` goes\nthrough the connector and skips any key already present, so an operator's **rewording and reordering\nsurvive** — those keep the key. **Deletion does not**: `have` is built from the rows that exist, so a\ndeleted key is absent and the next boot re-creates it. The supported way to retire a baseline quest is\n`active: false`, which `parseQuest` drops while leaving a row the seeder can still see. It\nthrows a `TypeError` rather than no-op'ing when handed something without `read` / `create`, and\nretries `SQLITE_BUSY` across five attempts: at boot against single-writer SQLite a swallowed lock race meant\nno quests until the next restart, and an empty checklist reports `percent: 100`, which reads as\n\"you have finished onboarding\". Its one exception is a **backfill** — a baseline quest's `feature`\nis written onto a row that predates the field (the property *absent*, not empty), because without\nit every instance that had ever booted would keep an incoherent checklist forever while a fresh\nclone looked correct.\n\n## Verify\n\n```sh\npnpm --filter @leumas/progression test   # node --test test/*.test.js — levels, catalog, quests\npnpm smoke:progression                   # ledger, composite dedupe, admin gate, badges  (3941)\npnpm smoke:progression-automation        # award-xp cannot be used to mint XP            (3944)\npnpm smoke:activity                      # the merge, the cursor walk, scoping           (3942)\npnpm smoke:operator                      # the look write path + the avatar ETag         (3940)\npnpm check:deck                          # bundle, asset and budget guards for the deck\n```\n\nThe three test files run under a bare `node --test` with no transform, which is why the frame and\naura catalogs are plain data in `@leumas/avatar` rather than inside a `.jsx` component:\n`catalog.test.js` is what stands between a one-character key typo and a cosmetic that is equippable\nand visibly does nothing. The dev DB is single-writer, so if `pnpm dev` holds `data/leumas.db` the\nsmokes report `database is locked` — point `LEUMAS_DB_URL` at a copy.\n\n## Gating & surface\n\n- `requireAuth` at the mount; `POST /award`, `POST /recompute` and `GET /user/:userId`\n  additionally require the `admin` role. `requireRole` is injected and the inline `isAdmin` check\n  is the fallback, not the belt — an award route that degrades to auth-only when a dependency is\n  missing is exactly the hole that ships.\n- **Studio:** Accounts → **Progression** (`/admin/progression`, nav id `progression`, surface\n  `ProgressionManager`) and Accounts → **Cosmetics** (`/admin/cosmetics`, nav id `cosmetics`,\n  surface `CosmeticsManager`) — both in `products/leumas-studio/src/admin/nav.manifest.js`.\n- The curve is also a domain adapter, `shared/engines/adapters/domain/progression`: six pure tools\n  (`xpForLevel`, `levelForXp`, `progressFor`, `rankFor`, `levelTable`, `evaluateUnlock`) over the\n  same `levels.js`, so an agent's answer always matches what the user sees.\n- **Two different \"avatars\".** `shared/engines/adapters/domain/avatar` is the pre-existing\n  deterministic identicon generator — what a `gen:<tool>/<seed>` portrait resolves through, and\n  where the pure look tools (`composeLook`, `listCosmetics`, `renderCard`, `suggestLook`) live.\n  `@leumas/avatar` is the look/slot contract this engine imports.\n",
  "source": {
    "path": "shared/engines/progression/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 14857,
    "hash": "79f7ab5108a0f0edab41fd301f626ec59e9b27ea"
  },
  "urls": {
    "html": "/p/engines/progression",
    "json": "/docs/engines/progression.json",
    "md": "/docs/engines/progression.md"
  },
  "links": {
    "composes": [
      "pkg:@leumas/actions",
      "pkg:@leumas/avatar",
      "pkg:@leumas/db",
      "pkg:@leumas/schemas"
    ],
    "usedBy": [
      "pkg:@leumas/router-kit"
    ],
    "product": [],
    "howTo": [
      "how-to:operator-deck"
    ],
    "skills": []
  },
  "exports": null
}
