483 lines
20 KiB
Markdown
Raw Normal View History

Fix circular import, primary-admin NULL trap, and add gateway quota reset Restores a working import graph and closes two data-correctness bugs, plus adds a reset for the AI gateway's rolling 24h spend. Circular import: database/__init__ -> engagement -> content -> utils -> database made the package unimportable. get_project_devlog moves out of database/engagement.py into content.py, where enrich_items already lives. Primary administrator: _can_hold_primary_admin read is_active with bool(row.get("is_active")), so an admin row whose is_active column is SQL NULL (any row predating the column) was treated as deactivated and skipped. Every other site defaults an unknown is_active to active; this one now does too. Profile JSON: xp_next_level and xp_progress_pct were computed but only put on the top-level context, never on profile_user, so they serialised as null even though UserOut declares them and the API docs document them as embedded there. Gateway quota reset: a cap previously lifted only with the passage of time. quota.reset upserts a watermark row into gateway_quota_resets, scoped by the same three nullable dimensions as a quota rule, and spent_24h sums from max(24h cutoff, watermark). No ledger row is deleted, so the cost analytics on /admin/ai-usage stay intact. Reaches every surface: POST /admin/gateway/quota-resets, a per-rule Reset spend button, the Devii tool gateway_quota_reset (confirm-gated), devplace gateway quota reset, and the API docs. Admin's Reset all quotas now stamps a global gateway watermark too, which is what a caller stuck on "AI gateway daily quota exceeded" needed. Startup: _backfill_gamification swept every xp=0 user on every boot in every worker and could never converge, since a user with no content earns no XP. It now intersects pending users with _milestone_candidates(). db.tables is a live reflection, so it is hoisted out of the loops that probed it per row. Docker: the dependency layer now depends on pyproject.toml only, so a source edit no longer reinstalls every dependency and re-downloads Chromium. Adds start_interval so the healthcheck probes during the start period, and a docker-reload target, since docker-up does not restart an unchanged container. Adds events.md, the audit event catalogue that README, CLAUDE.md, the quiz docs and the tooling all referenced but which never existed: 288 keys across 28 categories, including the families built from a variable at the call site. Test fixes: both devlog helpers dated post 0 as the newest while the tests assumed post 2 was; a profile login posted username= to a form that takes email=; a devlog assertion matched six buttons under strict mode; and the primary-admin tests seeded founders newer than the back-dated fixture admin, so they only passed without the api tier. Full suite: 2989 passed, 1 skipped.
2026-07-27 11:17:48 +02:00
# Audit event catalogue
retoor <retoor@molodetz.nl>
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 288 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
## Rules
- An event key is `<domain>.<noun>.<verb>` (or `<domain>.<verb>` when the domain is the noun). The domain is the first segment and MUST have an entry in `CATEGORY_BY_PREFIX`; an unmapped domain resolves to category `other` and is a bug.
- `target_type` names the entity acted upon, not the event (`gateway_quota`, not `gateway_quota_reset`).
- Every row carries a result: `success`, `failure`, `denied`. A refused action is recorded with `denied`, never dropped.
- Actor kinds: `user`, `guest`, `system`, `cli`, `service`. Origins: `web`, `api`, `devii`, `cli`, `service`, `scheduler`, `devrant`.
- The recorder never raises into its caller; a failed audit write is logged and swallowed so it can never break the action it describes.
- Some families are built from a variable at the call site (`vote.{target_type}.{direction}`, `comment.create.{target_type}`, `quiz.question.{action}`, `profile.customization.{scope}.toggle`, and the `create`/`edit`/`delete` verbs the shared `content.py` helpers emit per content type). Every member of those families is listed below individually.
- Adding an event means all three of: the key in this file, a `CATEGORY_BY_PREFIX` entry for a new domain, and the recorder call at the mutation point.
## Categories
| Category | Keys |
|---|---|
| `account` | 10 |
| `admin` | 21 |
| `ai` | 10 |
| `attachment` | 5 |
| `auth` | 9 |
| `backup` | 2 |
| `cli` | 42 |
| `container` | 12 |
| `content` | 37 |
| `database` | 4 |
| `devii` | 18 |
| `email` | 6 |
| `engagement` | 23 |
| `game` | 6 |
| `ingress` | 1 |
| `message` | 2 |
| `news` | 9 |
| `notification` | 4 |
| `project` | 11 |
| `project_files` | 14 |
| `pubsub` | 1 |
| `push` | 2 |
| `reward` | 4 |
| `security` | 3 |
| `service` | 5 |
| `social` | 10 |
| `telegram` | 5 |
| `tools` | 12 |
**Total: 288 keys.**
## Account and profile (`account`)
| Event key | Recorded in |
|---|---|
| `profile.ai_correction` | `routers/profile/ai_correction.py` |
| `profile.ai_modifier` | `routers/profile/ai_modifier.py` |
| `profile.api_key.regenerate` | `routers/profile/index.py` |
| `profile.avatar.regenerate` | `routers/profile/avatar.py` |
| `profile.customization.global.toggle` | `routers/profile/customization.py` |
| `profile.customization.pagetype.toggle` | `routers/profile/customization.py` |
| `profile.interactions` | `routers/profile/interactions.py`, `services/devii/actions/dispatcher.py` |
| `profile.notification.reset` | `routers/profile/notifications.py` |
| `profile.notification.toggle` | `routers/profile/notifications.py` |
| `profile.update` | `routers/devrant/auth.py`, `routers/profile/index.py` |
## Administration (`admin`)
| Event key | Recorded in |
|---|---|
| `admin.ai_quota.reset_all` | `routers/admin/aiquota.py` |
| `admin.ai_quota.reset_guests` | `routers/admin/aiquota.py` |
| `admin.backup.delete` | `routers/admin/backups.py` |
| `admin.backup.run` | `routers/admin/backups.py` |
| `admin.backup_schedule.create` | `routers/admin/backups.py` |
| `admin.backup_schedule.delete` | `routers/admin/backups.py` |
| `admin.backup_schedule.toggle` | `routers/admin/backups.py` |
| `admin.backup_schedule.update` | `routers/admin/backups.py` |
| `admin.devii_task.delete` | `routers/admin/devii_tasks.py` |
| `admin.devii_task.disable` | `routers/admin/devii_tasks.py` |
| `admin.game.era_end` | `routers/admin/game.py` |
| `admin.game.era_start` | `routers/admin/game.py` |
| `admin.notification.default` | `routers/admin/notifications.py` |
| `admin.setting.update` | `docs_api/groups/admin.py`, `routers/admin/settings.py` |
| `admin.trash.purge` | `routers/admin/trash.py` |
| `admin.trash.restore` | `routers/admin/trash.py` |
| `admin.user.active.disable` | `routers/admin/users.py` |
| `admin.user.active.enable` | `routers/admin/users.py` |
| `admin.user.ai_quota.reset` | `routers/admin/users.py` |
| `admin.user.password.reset` | `routers/admin/users.py` |
| `admin.user.role.change` | `routers/admin/users.py` |
## AI gateway (`ai`)
| Event key | Recorded in |
|---|---|
| `ai.clippy.chat` | `routers/devii.py` |
| `ai.gateway.call` | `services/openai_gateway/gateway.py`, `services/openai_gateway/usage.py` |
| `ai.quota.exceeded` | `routers/devii.py`, `services/openai_gateway/service.py` |
| `gateway.model.delete` | `routers/admin/gateway_configs.py` |
| `gateway.model.update` | `routers/admin/gateway_configs.py` |
| `gateway.provider.delete` | `routers/admin/gateway_configs.py` |
| `gateway.provider.update` | `routers/admin/gateway_configs.py` |
| `gateway.quota.reset` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
| `gateway.quota_rule.delete` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
| `gateway.quota_rule.update` | `cli/gateway.py`, `routers/admin/gateway_configs.py` |
## Attachments (`attachment`)
| Event key | Recorded in |
|---|---|
| `attachment.delete` | `routers/admin/media.py`, `routers/media.py` |
| `attachment.rename` | `routers/uploads.py` |
| `attachment.restore` | `routers/media.py` |
| `attachment.upload` | `routers/uploads.py` |
| `attachment.upload_url` | `routers/uploads.py` |
## Authentication (`auth`)
| Event key | Recorded in |
|---|---|
| `auth.account.disable` | `routers/devrant/auth.py` |
| `auth.login.failure` | `routers/auth/login.py`, `routers/devrant/auth.py` |
| `auth.login.success` | `routers/auth/login.py`, `routers/devrant/auth.py` |
| `auth.logout` | `routers/auth/logout.py` |
| `auth.password.forgot_request` | `routers/auth/forgotpassword.py` |
| `auth.password.reset_complete` | `routers/auth/resetpassword.py` |
| `auth.signup` | `routers/auth/signup.py`, `routers/devrant/auth.py` |
| `auth.token.failure` | `routers/auth/token.py` |
| `auth.token.issued` | `routers/auth/token.py` |
## Backups (`backup`)
| Event key | Recorded in |
|---|---|
| `job.backup.complete` | `services/backup/service.py` |
| `job.backup.failed` | `services/backup/service.py` |
## Command line (`cli`)
| Event key | Recorded in |
|---|---|
| `cli.apikey.backfill` | `cli/apikeys.py` |
| `cli.apikey.reset` | `cli/apikeys.py` |
| `cli.attachments.prune` | `cli/attachments.py` |
| `cli.backups.clear` | `cli/backups.py` |
| `cli.backups.prune` | `cli/backups.py` |
| `cli.backups.run` | `cli/backups.py` |
| `cli.containers.gc_workspaces` | `cli/containers.py` |
| `cli.containers.prune` | `cli/containers.py` |
| `cli.containers.prune_builds` | `cli/containers.py` |
| `cli.containers.reconcile` | `cli/containers.py` |
| `cli.deepsearch.clear` | `cli/jobs.py` |
| `cli.deepsearch.prune` | `cli/jobs.py` |
| `cli.devii.lessons.clear` | `cli/devii.py` |
| `cli.devii.lessons.prune` | `cli/devii.py` |
| `cli.devii.quota.reset` | `cli/devii.py` |
| `cli.devii.task.disable` | `cli/devii.py` |
| `cli.devii.task.prune` | `cli/devii.py` |
| `cli.emoji.sync` | `cli/migrate.py` |
| `cli.forks.clear` | `cli/jobs.py` |
| `cli.forks.prune` | `cli/jobs.py` |
| `cli.game.era.end` | `cli/game.py` |
| `cli.game.era.start` | `cli/game.py` |
| `cli.game.market.prune` | `cli/game.py` |
| `cli.game.steals.prune` | `cli/game.py` |
| `cli.isslop.analyze` | `cli/jobs.py` |
| `cli.isslop.clear` | `cli/jobs.py` |
| `cli.isslop.prune` | `cli/jobs.py` |
| `cli.messaging.prune_tickets` | `cli/messaging.py` |
| `cli.news.clear` | `cli/news.py` |
| `cli.news.sanitize` | `cli/news.py` |
| `cli.quiz.prune` | `cli/quiz.py` |
| `cli.role.set` | `cli/roles.py` |
| `cli.seo.clear` | `cli/jobs.py` |
| `cli.seo.prune` | `cli/jobs.py` |
| `cli.seo_meta.clear` | `cli/jobs.py` |
| `cli.seo_meta.prune` | `cli/jobs.py` |
| `cli.token.issue` | `cli/tokens.py` |
| `cli.token.prune` | `cli/tokens.py` |
| `cli.token.revoke` | `cli/tokens.py` |
| `cli.token.revoke_all` | `cli/tokens.py` |
| `cli.zips.clear` | `cli/jobs.py` |
| `cli.zips.prune` | `cli/jobs.py` |
## Containers (`container`)
| Event key | Recorded in |
|---|---|
| `container.instance.configure` | `routers/admin/containers.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.shell.close` | `routers/projects/containers/instances.py` |
| `container.instance.shell.open` | `routers/projects/containers/instances.py` |
| `container.instance.start` | `docs_api/groups/admin.py` |
| `container.instance.status` | `services/containers/service.py` |
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.reconcile.action` | `services/containers/service.py` |
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
| `container.schedule.delete` | `routers/projects/containers/schedules.py` |
## Content (`content`)
| Event key | Recorded in |
|---|---|
| `comment.create.gist` | `content.py` |
| `comment.create.issue` | `content.py` |
| `comment.create.news` | `content.py` |
| `comment.create.post` | `content.py` |
| `comment.create.project` | `content.py` |
| `comment.create.quiz` | `content.py` |
| `comment.delete` | `content.py`, `routers/comments.py` |
| `comment.edit` | `content.py`, `routers/comments.py` |
| `gist.create` | `content.py` |
| `gist.delete` | `content.py` |
| `gist.edit` | `content.py` |
| `issue.attachment.add` | `routers/issues/attachments.py` |
| `issue.attachment.delete` | `routers/issues/attachments.py` |
| `issue.comment` | `routers/issues/comment.py` |
| `issue.create` | `services/jobs/issue_create_service.py` |
| `issue.create.request` | `routers/issues/create.py` |
| `issue.planning.generate` | `services/jobs/planning_service.py` |
| `issue.planning.request` | `routers/issues/planning.py` |
| `issue.status` | `routers/issues/status.py` |
| `issue.sync.reply` | `services/gitea/service.py` |
| `issue.sync.status` | `services/gitea/service.py` |
| `post.create` | `content.py` |
| `post.delete` | `content.py` |
| `post.edit` | `content.py` |
| `quiz.attempt.answer` | `routers/quizzes/attempts.py` |
| `quiz.attempt.finish` | `routers/quizzes/attempts.py` |
| `quiz.attempt.start` | `routers/quizzes/attempts.py` |
| `quiz.create` | `content.py` |
| `quiz.delete` | `content.py` |
| `quiz.edit` | `content.py` |
| `quiz.grade.failed` | `routers/quizzes/attempts.py` |
| `quiz.import` | `routers/quizzes/index.py` |
| `quiz.publish` | `routers/quizzes/index.py` |
| `quiz.question.create` | `routers/quizzes/questions.py` |
| `quiz.question.delete` | `routers/quizzes/questions.py` |
| `quiz.question.edit` | `routers/quizzes/questions.py` |
| `quiz.question.reorder` | `routers/quizzes/questions.py` |
## Database API (`database`)
| Event key | Recorded in |
|---|---|
| `database.access.denied` | `routers/dbapi/_shared.py` |
| `database.nl.design` | `routers/dbapi/nl.py` |
| `database.query` | `routers/dbapi/query.py` |
| `database.query.async` | `routers/dbapi/query.py` |
## Devii assistant (`devii`)
| Event key | Recorded in |
|---|---|
| `devii.behavior.update` | `services/devii/actions/dispatcher.py` |
| `devii.customization.css.set` | `services/devii/actions/dispatcher.py` |
| `devii.customization.js.set` | `services/devii/actions/dispatcher.py` |
| `devii.customization.reset` | `services/devii/actions/dispatcher.py` |
| `devii.lesson.forget` | `services/devii/actions/dispatcher.py` |
| `devii.lesson.reflect` | `services/devii/actions/dispatcher.py` |
| `devii.notification.reset` | `services/devii/actions/dispatcher.py` |
| `devii.notification.set` | `services/devii/actions/dispatcher.py` |
| `devii.task.blocked` | `services/devii/tasks/scheduler.py` |
| `devii.task.create` | `services/devii/actions/dispatcher.py` |
| `devii.task.deferred` | `services/devii/tasks/scheduler.py` |
| `devii.task.delete` | `services/devii/actions/dispatcher.py` |
| `devii.task.execute` | `services/devii/tasks/scheduler.py` |
| `devii.task.update` | `services/devii/actions/dispatcher.py` |
| `devii.tool.create` | `services/devii/actions/dispatcher.py` |
| `devii.tool.delete` | `services/devii/actions/dispatcher.py` |
| `devii.tool.update` | `services/devii/actions/dispatcher.py` |
| `devii.turn` | `services/devii/session/core.py` |
## Email (`email`)
| Event key | Recorded in |
|---|---|
| `email.account.delete` | `services/devii/actions/dispatcher.py` |
| `email.account.set` | `services/devii/actions/dispatcher.py` |
| `email.message.delete` | `services/devii/actions/dispatcher.py` |
| `email.message.flag` | `services/devii/actions/dispatcher.py` |
| `email.message.move` | `services/devii/actions/dispatcher.py` |
| `email.send` | `services/devii/actions/dispatcher.py` |
## Engagement (`engagement`)
| Event key | Recorded in |
|---|---|
| `bookmark.add` | `content.py`, `routers/bookmarks.py` |
| `bookmark.remove` | `content.py`, `routers/bookmarks.py` |
| `poll.create` | `routers/posts.py` |
| `poll.vote.cast` | `routers/polls.py` |
| `poll.vote.change` | `routers/polls.py` |
| `poll.vote.clear` | `routers/polls.py` |
| `reaction.add` | `routers/reactions.py` |
| `reaction.remove` | `routers/reactions.py` |
| `vote.comment.clear` | `content.py` |
| `vote.comment.down` | `content.py` |
| `vote.comment.up` | `content.py` |
| `vote.gist.clear` | `content.py` |
| `vote.gist.down` | `content.py` |
| `vote.gist.up` | `content.py` |
| `vote.post.clear` | `content.py` |
| `vote.post.down` | `content.py` |
| `vote.post.up` | `content.py` |
| `vote.project.clear` | `content.py` |
| `vote.project.down` | `content.py` |
| `vote.project.up` | `content.py` |
| `vote.quiz.clear` | `content.py` |
| `vote.quiz.down` | `content.py` |
| `vote.quiz.up` | `content.py` |
## Code Farm (`game`)
| Event key | Recorded in |
|---|---|
| `game.cosmetic.buy` | `routers/game/index.py` |
| `game.defense.downgrade` | `routers/game/index.py` |
| `game.defense.upgrade` | `routers/game/index.py` |
| `game.grant.claim` | `routers/game/index.py` |
| `game.infrastructure.buy` | `routers/game/index.py` |
| `game.prestige` | `routers/game/index.py` |
## Container ingress (`ingress`)
| Event key | Recorded in |
|---|---|
| `proxy.access` | `routers/proxy.py` |
## Direct messages (`message`)
| Event key | Recorded in |
|---|---|
| `message.read_on_view` | `routers/messages.py` |
| `message.send` | `services/messaging/persist.py` |
## News (`news`)
| Event key | Recorded in |
|---|---|
| `news.delete` | `routers/admin/news.py` |
| `news.featured.toggle` | `routers/admin/news.py` |
| `news.landing.toggle` | `routers/admin/news.py` |
| `news.publish.toggle` | `routers/admin/news.py` |
| `news.service.draft` | `services/news/service.py` |
| `news.service.ingest` | `services/news/service.py` |
| `news.service.landing` | `services/news/service.py` |
| `news.service.publish` | `services/news/service.py` |
| `news.service.reject` | `services/news/service.py` |
## Notifications (`notification`)
| Event key | Recorded in |
|---|---|
| `notification.create` | `utils/notifications.py` |
| `notification.open` | `routers/notifications.py` |
| `notification.read.all` | `routers/devrant/notifs.py`, `routers/notifications.py` |
| `notification.read.one` | `routers/notifications.py` |
## Projects (`project`)
| Event key | Recorded in |
|---|---|
| `project.create` | `content.py` |
| `project.delete` | `content.py` |
| `project.edit` | `content.py` |
| `project.fork.complete` | `services/jobs/fork_service.py` |
| `project.fork.failed` | `services/jobs/fork_service.py` |
| `project.fork.request` | `routers/projects/index.py` |
| `project.readonly.disable` | `routers/projects/index.py` |
| `project.readonly.enable` | `routers/projects/index.py` |
| `project.visibility.private` | `routers/projects/index.py` |
| `project.visibility.public` | `routers/projects/index.py` |
| `project.zip.request` | `routers/projects/index.py` |
## Project files (`project_files`)
| Event key | Recorded in |
|---|---|
| `dir.create` | `routers/projects/files.py` |
| `file.append` | `routers/projects/files.py` |
| `file.delete` | `routers/projects/files.py` |
| `file.delete_lines` | `routers/projects/files.py` |
| `file.insert_lines` | `routers/projects/files.py` |
| `file.move` | `routers/projects/files.py` |
| `file.replace_lines` | `routers/projects/files.py` |
| `file.upload` | `routers/projects/files.py` |
| `file.write` | `routers/projects/files.py` |
| `file.write.create` | `routers/projects/files.py` |
| `file.write.overwrite` | `routers/projects/files.py` |
| `files.zip.request` | `routers/projects/files.py` |
| `job.zip.complete` | `services/jobs/zip_service.py` |
| `job.zip.failed` | `services/jobs/zip_service.py` |
## Pub/sub (`pubsub`)
| Event key | Recorded in |
|---|---|
| `pubsub.publish` | `routers/pubsub.py` |
## Web push (`push`)
| Event key | Recorded in |
|---|---|
| `push.subscribe` | `routers/push.py` |
| `push.update` | `routers/push.py` |
## Gamification (`reward`)
| Event key | Recorded in |
|---|---|
| `reward.badge.award` | `utils/badges.py` |
| `reward.level.up` | `utils/rewards.py` |
| `reward.streak.milestone` | `utils/rewards.py` |
| `reward.xp.grant` | `utils/rewards.py` |
## Security (`security`)
| Event key | Recorded in |
|---|---|
| `security.authz.denied` | `routers/admin/backups.py`, `services/devii/actions/dispatcher.py` |
| `security.maintenance.block` | `main.py` |
| `security.rate_limit.block` | `main.py` |
## Background services (`service`)
| Event key | Recorded in |
|---|---|
| `service.config.update` | `routers/admin/services.py` |
| `service.logs.clear` | `routers/admin/services.py` |
| `service.run_now` | `routers/admin/services.py` |
| `service.start` | `routers/admin/services.py` |
| `service.stop` | `routers/admin/services.py` |
## Social graph (`social`)
| Event key | Recorded in |
|---|---|
| `award.complete` | `services/jobs/award_service.py` |
| `award.failed` | `services/jobs/award_service.py` |
| `award.give` | `routers/profile/award.py` |
| `award.revoke` | `routers/admin/awards.py` |
| `follow.follow` | `routers/follow.py` |
| `follow.unfollow` | `routers/follow.py` |
| `relation.block` | `routers/relations.py` |
| `relation.mute` | `routers/relations.py` |
| `relation.unblock` | `routers/relations.py` |
| `relation.unmute` | `routers/relations.py` |
## Telegram (`telegram`)
| Event key | Recorded in |
|---|---|
| `telegram.pair.failure` | `services/telegram/bridge.py` |
| `telegram.pair.request` | `routers/profile/telegram.py` |
| `telegram.pair.success` | `services/telegram/bridge.py` |
| `telegram.send` | `services/devii/actions/dispatcher.py` |
| `telegram.unpair` | `routers/profile/telegram.py` |
## Developer tools (`tools`)
| Event key | Recorded in |
|---|---|
| `deepsearch.chat` | `routers/tools/deepsearch.py` |
| `deepsearch.run.complete` | `services/jobs/deepsearch/service.py` |
| `deepsearch.run.failed` | `services/jobs/deepsearch/service.py` |
| `deepsearch.run.request` | `routers/tools/deepsearch.py` |
| `isslop.run.complete` | `services/jobs/isslop/service.py` |
| `isslop.run.failed` | `services/jobs/isslop/service.py` |
| `isslop.run.request` | `routers/tools/isslop.py` |
| `seo.meta.failed` | `services/jobs/seo_meta_service.py` |
| `seo.meta.generate` | `services/jobs/seo_meta_service.py` |
| `seo.run.complete` | `services/jobs/seo/service.py` |
| `seo.run.failed` | `services/jobs/seo/service.py` |
| `seo.run.request` | `routers/tools/seo.py` |
## Mapped domains with no recorder
The `backup` prefix has a `CATEGORY_BY_PREFIX` entry but no call site emits a bare `backup.*` key - backup activity is recorded as `admin.backup*` (operator actions) and `job.backup.*` (worker outcomes), both of which resolve to the `backup` category through their own prefixes. Keep the mapping: it is what routes `job.backup.*` correctly.