assignment-service — business features

Who has to do which training by when, and has it happened yet · ← platform hub · entry points verified against tools/feature-docs/out/assignment-service.json

1. What it is

assignment-service is the service that says who has to do which training module by when. An admin picks a module and an audience (one person, a group, or the whole company), gives it a due date, and this service turns that into one row per person, pushes "new training assigned" to their phone, chases them with reminders until they finish, tells admins who is late, and answers every other service's question about a learner's outstanding training.

Who uses itAdmin web app (through the ingress, /v1/admin/assignments only) · the mobile app indirectly, via micro-learning-service-v2 · users-auth-service, micro-learning-service-v2 and notification-worker server-to-server · its own in-pod schedulers
RuntimeFastAPI (uvicorn, sync SQLAlchemy) · Deployment assignment-service, replicas: 1 (k8s/deployment.yaml:9), 5 in-process NATS/scheduler workers started from the app lifespan
DatabasePostgres logical DB assignments on the shared instance (ARCHITECTURE.md §3.4) · 15 alembic revisions · 12 tables
RedisDB index 4 is assigned and configured (k8s/configmap.yaml:8-11) but nothing in app/ imports redis — the service keeps no cache
NATS streamsMICRO_LEARNING only (subjects training.assignments.>, training.progress.>, training.module.completed, training.notifications.>) — shared with micro-learning and notification-worker (ARCHITECTURE.md §3.3)
External APIsNone. All outbound traffic is in-cluster HTTP: users-auth-service (groups, members, profiles, timezone), micro-learning-service-v2 (module validation, progress), notification-worker (device check)
Entry points61 HTTP routes (50 distinct handlers — 11 are mounted twice, see below) · 5 NATS consumer registrations over 3 subjects · 13 published subjects · 9 background tasks

The double-mounted router

app/main.py:158 mounts assignment_routes.router at /v1/admin and app/main.py:164 mounts the same router again at /v1. All 11 of its routes therefore answer on two paths, which is why the scan reports 61 routes for 50 handlers. Exactly one path of each pair is useful: the 10 admin routes are reachable only under /v1/admin/assignments… (the ingress rule, global-configs/k8s/users-auth-ingress.yaml:76-82), and the one employee route is called only at /v1/employee/assignments (micro-learning-service-v2, app/clients/assignment_service.py:31). The other 11 paths — /v1/assignments… and /v1/admin/employee/assignments — have no caller and no route to the outside world. The fix is to split the router in two (admin + employee) rather than to drop a mount, because dropping /v1 would take the live employee route with it. See §8.

2. Feature map

flowchart LR
  ADMIN["Admin web app"] --> ASSIGN["Assign training to people"]
  ADMIN --> TRACK["See who has done it"]
  EMP["Employee mobile app"] --> MINE["My assignments"]
  SVC["Other services"] --> ASK["Internal lookups"]
  SCHED["In-pod schedulers"] --> NUDGE["Reminders, escalation, digest"]
  ASSIGN --> R1["POST /v1/admin/assignments"]
  ASSIGN --> W1["expansion_worker (NATS)"]
  TRACK --> R2["GET /v1/admin/assignments/:id"]
  MINE --> R3["GET /v1/employee/assignments"]
  ASK --> R4["12 internal endpoints"]
  NUDGE --> W2["reminder + digest loops"]

3. Features

Admin

Assign a module to a person, a group, or everyone live

admin

An admin picks a published module, picks an audience, sets a start and a due date (the due date is mandatory), and optionally marks it required, sets a priority, or overrides the quiz retry/passing rules. Naming individual employees materialises their rows straight away, so the list is correct the moment the call returns. A group or company audience is resolved in the background instead (next feature), and the response says expansion_status: "pending" so the UI can show "being rolled out".

Entry points
POST /v1/admin/assignments · duplicate mount POST /v1/assignments (dead)
Touches
assignments, assignment_targets, assignment_recipients; micro-learning (module validation), users-auth (target validation); publishes assignment.created
Related
BUSINESS_LOGIC.md §Assignment Creation Flow · question-retry-settings.md
Evidence
app/routes/assignment_routes.py:56 · app/services/assignment_service.py:282 · docs/BUSINESS_LOGIC.md:70

Change the deadline, the priority, or the audience live

admin

An admin can move the due date, raise the priority, flip "required", or replace the target list on a live assignment. Replacing the targets re-runs the audience resolution: new people get added and pushed, people who left the audience are retired. A cancelled assignment can no longer be edited.

Entry points
PATCH /v1/admin/assignments/{assignment_id} · duplicate mount PATCH /v1/assignments/{assignment_id} (dead)
Touches
assignments, assignment_targets; publishes assignment.updated and, on a target change, assignment.retargeted
Related
BUSINESS_LOGIC.md §Assignment Update Flow
Evidence
app/routes/assignment_routes.py:463 · app/services/assignment_service.py:790,819 · docs/BUSINESS_LOGIC.md:122

Cancel an assignment live

admin

Cancelling is a soft delete: the assignment stays in the database with status = "canceled", who cancelled it and when, so history and reports survive. An assignment everybody already finished cannot be cancelled (422) — you do not un-assign completed training.

Entry points
DELETE /v1/admin/assignments/{assignment_id} · duplicate mount DELETE /v1/assignments/{assignment_id} (dead)
Touches
assignments; publishes assignment.canceled
Related
assignment-details-api-gaps.md §notes; known defect: the "everyone completed" check reads at most 1000 progress rows (to-be-reviewed AS-A14)
Evidence
app/routes/assignment_routes.py:508 · app/services/assignment_service.py:982 · docs/BUSINESS_LOGIC.md:145

The assignment list and its counters live

admin

The main admin screen: every assignment of the tenant, filterable by module, status and type, paginated, each row carrying a completed/assignees ratio. A separate counters call feeds the dashboard tiles (active, overdue, completed).

Entry points
GET /v1/admin/assignments (live) · GET /v1/admin/assignments/stats (suspect — no doc or in-repo caller names it) · duplicate mounts GET /v1/assignments, GET /v1/assignments/stats (dead)
Touches
assignments, assignment_recipients, assignment_progress
Related
BUSINESS_LOGIC.md §Assignment Listing
Evidence
app/routes/assignment_routes.py:126,168 · docs/BUSINESS_LOGIC.md:165,504

One assignment in detail: audience, progress, history live

admin

Opening an assignment shows who created it, the module card, the audience as the admin described it, a completion summary and trend, the per-person roster with status and search, and a typed activity feed ("assigned", "reminder sent", "completed", "due date changed"). Cross-service blocks degrade to null rather than failing the screen when an upstream is down.

Entry points
GET /v1/admin/assignments/{assignment_id}, GET /v1/admin/assignments/{assignment_id}/assignees, GET /v1/admin/assignments/{assignment_id}/activity · duplicate mounts under /v1/assignments/… (dead)
Touches
assignments, assignment_recipients, assignment_progress, assignment_events; users-auth (profiles), micro-learning (module card)
Related
assignment-details-api-gaps.md (what shipped vs what was deliberately not built)
Evidence
app/routes/assignment_routes.py:194,247,297 · docs/assignment-details-api-gaps.md:12-15 · docs/manual-reminders-fe-integration.md:231-234

Nudge people now, or schedule the nudge live

admin

An admin can send an extra reminder outside the automatic cadence: pick the audience (everyone / only the incomplete / only the overdue), optionally add a 160-character message, optionally pick a time. The call answers 202 with how many people it will reach; a scheduled send is stored and delivered by the scheduler tick. A per-assignment log shows every batch that went out, manual and automatic, newest first.

Entry points
POST /v1/admin/assignments/{assignment_id}/reminders, GET /v1/admin/assignments/{assignment_id}/notifications · duplicate mounts under /v1/assignments/… (dead)
Touches
scheduled_reminders, assignment_events; publishes training.assignments.reminders.ten.*
Related
manual-reminders-fe-integration.md (frontend contract)
Evidence
app/routes/assignment_routes.py:379,338 · app/services/manual_reminder_service.py:232 · docs/manual-reminders-fe-integration.md:14,191

Progress read-outs, analytics and completion reports suspect

admin

A second, older admin surface: per-assignment progress summaries, per-person progress lists, a user's assignment history, analytics, a manual "re-sync from micro-learning" button, and JSON completion reports stored in the database for later download. It works, but the ingress has no rule for /v1/admin/progress, so today the admin app cannot reach any of it — those paths fall through to the users-auth catch-all. Only in-repo smoke scripts call them.

Entry points
12 routes under /v1/admin/progress/… (see §4)
Touches
assignment_progress, progress_milestones, progress_events, completion_reports, assignment_statistics; micro-learning progress API
Related
Reports are JSON rows only — no CSV/PDF, no files, no email (assignment-details-api-gaps.md §5). Cross-tenant report download was fixed (bug-hunt #1); POST /v1/admin/progress/test-event still trusts a body-supplied tenant (AS-A2)
Evidence
app/routes/progress_routes.py:25-531 · global-configs/k8s/users-auth-ingress.yaml:76-82 (no /v1/admin/progress rule) · assignment-service/scripts/smoke_test_group_assignment.py:463

Event console: audit trail, dead letters, retries dead

admin

An operator surface over the service's own event bookkeeping: list processed events, inspect one, read statistics, list dead letters, resolve a dead letter, retry failures, archive old rows, poke the worker, inject a test event. Nothing calls it: it is gated by the internal service key (not an admin JWT), no other service in the repo calls it, and the ingress has no rule for /v1/admin/events. The dead-letter listing could not answer even if something did call it — it is shadowed by /events/{event_id}, registered first.

Entry points
9 routes under /v1/admin/events/… (see §4)
Touches
assignment_events, event_processing_logs, event_dead_letters, event_deduplication
Related
known defects: route shadowing (bug-hunt #20), client-supplied tenant on internal reads (#2, #3)
Evidence
app/routes/event_routes.py:25-372 (all Depends(validate_internal_token)) · no caller repo-wide · global-configs/k8s/users-auth-ingress.yaml (no /v1/admin/events rule)

Notification preview and per-employee preferences dead

admin

"Show me who would be notified for this assignment", plus get/set one employee's notification preferences and a worker-status probe. All four are internal-key routes with no caller in the repo, and the ingress sends /v1/admin/notifications to notification-worker instead — so even an external caller reaches a different service. The preferences pair is doubly inert: its HTTP client points at a notifications-service host that does not exist and fails open to defaults.

Entry points
POST /v1/admin/notifications/preview-assignment, GET /v1/admin/notifications/preferences/{employee_id}, PUT /v1/admin/notifications/preferences/{employee_id}, GET /v1/admin/notifications/workers/status
Touches
assignment_recipients; the (non-existent) preferences host
Related
known defect: to-be-reviewed AS-A13 (preferences client points at a dead host, fails open)
Evidence
app/routes/notification_routes.py:22,92,127,160 · global-configs/k8s/users-auth-ingress.yaml:142-149 (prefix → notification-worker-service) · app/services/notification_service.py:122-126

Employee (mobile)

My assignments live

employee (mobile)

The learner's own list: the active assignments for the employee in the JWT, with due date, priority, required flag and the quiz overrides that apply to the assigned module. The mobile app does not call it directly — micro-learning-service-v2 calls it while assembling the module cards, which is why the mobile module card can say "assigned, due Friday".

Entry points
GET /v1/employee/assignments (live) · duplicate mount GET /v1/admin/employee/assignments (dead)
Touches
assignments, assignment_recipients
Related
question-retry-settings.md (the learner-facing quiz knobs travel on this response); known defect: fetches all rows then paginates in memory (to-be-reviewed AS-A15)
Evidence
app/routes/assignment_routes.py:543 · micro-learning-service-v2/app/clients/assignment_service.py:31 · ARCHITECTURE.md §3.2 H13

Internal (other services)

"Does this person / group still have training outstanding?" live

internal

Two cheap yes/no lookups other services need before they act. users-auth asks for the active-assignment count per group so an admin cannot delete a group that still owes training. notification-worker asks whether a user has any active assignment before it bothers them with a push.

Entry points
POST /v1/internal/groups/active-count, GET /v1/internal/users/{user_id}/active-assignments
Touches
assignments, assignment_targets, assignment_recipients
Related
ARCHITECTURE.md §3.2 H20, H26; known defect: the group count includes assignments everybody already finished (bug-hunt #24)
Evidence
users-auth-service/app/routes/groups.py:243 · notification-worker/app/services/assignment_client.py:63

Feeding other services' admin screens live

internal

Five read endpoints exist purely so another service can render a screen it owns: users-auth's per-user admin page (their assignments and their activity feed), and micro-learning's admin overview, module detail, per-learner module card and "is the rollout finished yet" indicator.

Entry points
GET /v1/internal/users/{user_id}/assignments, GET /v1/internal/users/{user_id}/activity, GET /v1/internal/assignments/overview, GET /v1/internal/modules/{module_id}/assignments, GET /v1/internal/modules/{module_id}/employees/{employee_id}/assignment, GET /v1/internal/assignments/{assignment_id}/expansion-status
Touches
assignments, assignment_recipients, assignment_progress, assignment_events
Related
ARCHITECTURE.md §3.2 H13, H20
Evidence
users-auth-service/app/clients/training_clients.py:120,134 · micro-learning-service-v2/app/clients/assignment_service.py:87,109,132,154

Cancel the assignments of a module that was deleted live

internal

When an admin deletes or unpublishes a module in micro-learning, that service calls back here to cancel the assignments pointing at it, so nobody is chased to complete training that no longer exists. The cancellation is attributed to whoever deleted the module.

Entry points
POST /v1/internal/assignments/{assignment_id}/cancel
Touches
assignments; publishes assignment.canceled
Related
ARCHITECTURE.md §3.2 H13
Evidence
app/routes/internal.py:394 · micro-learning-service-v2/app/clients/assignment_service.py:203

GDPR: one person's assignment data live

internal

When a subject asks for a copy of their data, users-auth fans out to the same endpoint on every service that holds personal data and merges the slices. This service's slice is the person's assignments, recipient rows, progress, milestones and reminder state.

Entry points
GET /v1/internal/export/{user_id}
Touches
every learner-scoped table in the assignments DB
Related
ARCHITECTURE.md §3.2 H23 (GDPR export fan-out, runs on a CronJob)
Evidence
app/routes/internal.py:442 · app/services/data_export_service.py · users-auth-service/app/services/gdpr_export.py:147,156

Patching one assignment's roster by hand dead

internal

Two internal endpoints to read an assignment's employee list and to bolt one extra employee onto it. Nothing in the platform calls either. The write half also silently dropped its row until recently — it returned a recipient_id for an INSERT that was rolled back (fixed, but it still has no caller).

Entry points
GET /v1/internal/assignments/{assignment_id}/employees, POST /v1/internal/assignments/{assignment_id}/employees/{employee_id}
Touches
assignment_recipients
Related
bug-hunt #9 ("no live inter-service caller of this POST was found")
Evidence
app/routes/internal.py:178,246 · no caller repo-wide for either path

Background

Turning "the whole company" into a list of people live

background

Group and company assignments are not expanded in the request — the create call publishes an event and returns. The expansion worker picks it up, asks users-auth who is in those groups (or in the tenant), writes one recipient row per person, pushes "New training assigned" to each of them, and flips the assignment to expansion_status = "completed". Retargeting runs the same path and also retires people who left the audience. Failures are published so admins are told the rollout did not happen.

Entry points
NATS training.assignments.>, durable expansion_worker, stream MICRO_LEARNING (handles the created and retargeted subjects only)
Touches
assignment_recipients, assignments; users-auth group members; publishes assignment.expanded, assignment.expansion_completed, assignment.expansion_failed and per-recipient training.notifications.ten.*.assignment.*
Related
This used to be a separate Deployment consuming the same subject with different dedup rules; that duplicate was merged away (ARCHITECTURE.md §6 M1, landed #501) and the in-pod worker is now the only expander (bug-hunt #14). Known defect: it logs the full service JWT (#6)
Evidence
app/main.py:76-77 (lifespan starts it) · app/workers/expansion_worker.py:142,150,490-495

Mirroring how far each person has got live

background

As a learner finishes each lesson, micro-learning announces the new lesson count and this worker mirrors it onto the person's row here — percentage, lessons done, started-at. That is what makes the admin list and the assignee roster show "3 of 8 lessons" instead of a flat 0, and it is what the reminder engine reads to stop nagging someone who is clearly making progress.

Entry points
NATS training.progress.assignment_progress, matched by the durable progress_event_worker filter training.progress.>
Touches
assignment_progress, progress_milestones, progress_events
Related
The publisher's docstring states the contract: without this event assignment-service would learn only about full completions and partial progress would read as 0. The handler now commits, so bug-hunt AS-A8's rollback half no longer applies. The worker's own comment at progress_event_worker.py:127 ("has no producer") and bug-hunt #8 / to-be-reviewed AS-A10 are stale on this point
Evidence
app/workers/progress_event_worker.py:109,119,188 (routes on assignment_progress), :315 (db.commit()) · publisher micro-learning-service-v2/app/events/events.py:125 via app/routes/progress_mobile_routes.py:1344

Knowing that someone finished live

background

When a learner finishes a module outright, micro-learning announces that too and this worker finds every assignment of that module for that person, drives it to 100%, sends the "Training completed" push once, and tells admins as soon as the last recipient is done. An hourly sweep re-reads progress for recent assignments so a dropped event cannot leave the numbers wrong forever.

Entry points
NATS training.module.completed, durable progress_module_completion_worker · hourly _periodic_sync_loop
Touches
assignment_progress, progress_milestones, progress_events, assignments.fully_completed_notified_at; publishes training.notifications.ten.*.assignment.* and training.notifications.ten.*.admin.*
Related
Both progress inputs are live and they are different signals: partial progress arrives on training.progress.assignment_progress (previous block), full completion on training.module.completed. The completion push had no production trigger at all until this consumer was added (to-be-reviewed A1, fixed). Known defects: a failed apply is acked as success (AS-A21 / bug-hunt #18), non-atomic completion-push idempotency (#12)
Evidence
app/main.py:61-64 · app/workers/progress_event_worker.py:136,144,53 · micro-learning-service-v2/app/events/events.py:80 (publisher)

Chasing people until they finish live

background

Every 5 minutes the scheduler asks, per person per assignment, "is it time to remind them again?" The interval comes from a priority × time-to-due matrix: critical work reminds twice a day in the last three days and daily once overdue, low-priority work only on the due day. People who are part-way through get reminded less often, finished people not at all, and the recipient's timezone and quiet hours are respected unless the admin overrode that.

Entry points
ReminderSchedulerWorker._looprun_tick(), tick REMINDER_SCHEDULER_TICK_SECONDS (default 300s)
Touches
assignment_reminders, assignment_events; users-auth (timezone/preferences); publishes training.assignments.reminders.ten.*
Related
SDD-OPER-007 · consumed by notification-worker's notification_worker_reminders durable. Known defect: the "I reminded them" row is committed before the publish, so a NATS hiccup skips a whole cadence step (to-be-reviewed AS-A7)
Evidence
app/main.py:56-58 · app/workers/reminder_scheduler_worker.py:34 · app/services/reminder_scheduler.py:37-63,496,524

Escalating a critical assignment nobody touched live

background

If an assignment is marked critical and a person has been reminded three times with zero progress, the scheduler fires one escalation event for that person — a final, louder nudge that ignores quiet hours. It fires once per person per assignment.

Entry points
ReminderScheduler._should_escalate_publish_escalation, inside the same tick; publishes training.assignments.escalations.ten.*
Touches
assignment_reminders.escalated
Related
This was unreachable twice over and both halves are now fixed: critical was rejected by the DB constraint until alembic/versions/2026_07_07_widen_priority_check.py, and the subject had no consumer until notification-worker added one (to-be-reviewed/README.md row #3/N2). It delivers to the employee, not a manager — there is no manager resolution
Evidence
app/services/reminder_scheduler.py:460-470,529 · alembic/versions/2026_07_07_widen_priority_check.py:29 · notification-worker/app/services/notification_service.py:1454-1467,2381

Announcing an assignment on the day it starts live

background

An assignment can be created with a future start date. Nobody is told about it until that day arrives; the same scheduler loop sweeps for assignments whose start day has come, pushes the "New training assigned" notification then, and stamps the assignment so it is announced exactly once.

Entry points
ReminderScheduler.run_start_activation_tick(), same loop as the reminders
Touches
assignments.start_notified_at; publishes training.notifications.ten.*.assignment.*
Related
alembic/versions/2026_07_07_add_start_notified_at.py
Evidence
app/workers/reminder_scheduler_worker.py:62-66 · app/services/reminder_scheduler.py:414

Delivering admin-scheduled reminders live

background

A manual reminder with a send_at is stored, not sent. The same loop sweeps for scheduled reminders that have come due, resolves the audience the admin chose at that moment, sends them, and records the batch in the assignment's notification log.

Entry points
ReminderScheduler.run_manual_reminder_tick(), same loop as the reminders
Touches
scheduled_reminders, assignment_events; publishes training.assignments.reminders.ten.*
Related
manual-reminders-fe-integration.md
Evidence
app/workers/reminder_scheduler_worker.py:68-72 · app/services/reminder_scheduler.py:347 · app/services/manual_reminder_service.py:232

The daily "who is late" digest for admins live

background

Once a day at 07:00 UTC each tenant's admins get one inbox item: how many learners and how many assignments are overdue, plus the three worst offenders. Tenants with nothing overdue get nothing — silence is the default. The event id is derived from tenant + date, so several pods firing cannot produce duplicate inbox rows.

Entry points
AdminDigestWorker._loop, checked every ADMIN_DIGEST_CHECK_SECONDS (default 60s), fires at ADMIN_OVERDUE_DIGEST_HOUR_UTC (default 7)
Touches
reads assignments, assignment_recipients, assignment_progress; publishes training.notifications.ten.*.admin.*
Related
PLAN-admin-notif-centre-tier1; consumed by notification-worker's notification_worker_admin durable
Evidence
app/main.py:67-70 · app/workers/admin_digest_worker.py:90 · app/services/admin_notifications.py:63

Event bookkeeping: audit trail, dedup, retry, cleanup live

background

Everything that happens to an assignment is written down: one audit row per event, a deduplication key so a redelivered event is not acted on twice, a processing log, and a dead-letter row when processing fails. Those audit rows are what the admin activity feed reads. Two side loops retry failed events and archive old ones.

Entry points
NATS training.assignments.>, durable enhanced_event_worker (skips created — that belongs to the expansion worker — and skips reminder/escalation subjects) · _retry_failed_events_loop · _cleanup_old_events_loop
Touches
assignment_events, event_deduplication, event_processing_logs, event_dead_letters
Related
None of it persisted until the missing commit was added (bug-hunt #7/#19, fixed). Known defects: acks on processing error (AS-A11/A13), and this durable was observed stalled with 120 unprocessed messages (ARCHITECTURE.md §4.1(7))
Evidence
app/main.py:51-53 · app/workers/enhanced_event_worker.py:41,42,90,196

Superseded duplicate notification consumer dead

background

An older worker that consumed the same assignment events and sent the same "New training assigned" pushes. It is deliberately not started — running it next to the two live consumers would double-publish every created notification. The file survives only because a test imports it.

Entry points
would be NATS training.assignments.>, durable notification_event_worker, stream MICRO_LEARNING
Touches
nothing — never instantiated at runtime
Related
to-be-reviewed A4/A12 (import removed on purpose; full deletion deferred until the test is decoupled)
Evidence
app/main.py:18-21 (the comment explaining the removal) · app/workers/notification_event_worker.py:61 · only other reference: tests/test_notifications.py:14

4. API reference

61 rows = every route in the scan JSON. dup in the Feature column marks the second mount of the shared router (see §1). Auth: admin JWT = tenant admin/manager bearer token plus the named scope; internal = X-Oper-Key or an internal-service JWT; employee JWT = a learner's token.

MethodPathAuthFeatureCallersVerdict
GET/health/livenonelivenesskubelet (k8s/deployment.yaml:98-101)live
GET/health/readynonereadiness (DB check)kubelet (k8s/deployment.yaml:87-90)live
POST/v1/admin/assignmentsadmin JWT · assignments.writeAssign a moduleadmin web app (docs/BUSINESS_LOGIC.md:70)live
GET/v1/admin/assignmentsadmin JWT · analytics.readAssignment listadmin web app (docs/BUSINESS_LOGIC.md:165)live
GET/v1/admin/assignments/statsadmin JWT · analytics.readDashboard countersnone found — ingressed, but no doc or in-repo caller names itsuspect
GET/v1/admin/assignments/{assignment_id}admin JWT · analytics.readAssignment detailadmin web app (docs/manual-reminders-fe-integration.md:231)live
PATCH/v1/admin/assignments/{assignment_id}admin JWT · assignments.writeEdit assignmentadmin web app (docs/BUSINESS_LOGIC.md:122)live
DELETE/v1/admin/assignments/{assignment_id}admin JWT · assignments.writeCancel assignmentadmin web app (docs/assignment-details-api-gaps.md:204)live
GET/v1/admin/assignments/{assignment_id}/assigneesadmin JWT · analytics.readAssignment detailadmin web app (docs/manual-reminders-fe-integration.md:232)live
GET/v1/admin/assignments/{assignment_id}/activityadmin JWT · analytics.readAssignment detailadmin web app (docs/manual-reminders-fe-integration.md:132,234)live
GET/v1/admin/assignments/{assignment_id}/notificationsadmin JWT · analytics.readReminder logadmin web app (docs/manual-reminders-fe-integration.md:191)live
POST/v1/admin/assignments/{assignment_id}/remindersadmin JWT · assignments.writeManual reminderadmin web app (docs/manual-reminders-fe-integration.md:14)live
GET/v1/admin/employee/assignmentsemployee JWTdup — employee route under the admin mountnone; not matched by the /v1/admin/assignments ingress prefixdead
GET/v1/admin/events/statisticsinternalEvent consolenonedead
GET/v1/admin/eventsinternalEvent consolenonedead
GET/v1/admin/events/{event_id}internalEvent consolenonedead
GET/v1/admin/events/dead-lettersinternalEvent consolenone; also shadowed by /events/{event_id}dead
POST/v1/admin/events/dead-letters/{dead_letter_id}/resolveinternalEvent consolenonedead
POST/v1/admin/events/retry-failedinternalEvent consolenonedead
POST/v1/admin/events/cleanupinternalEvent consolenonedead
GET/v1/admin/events/worker/statusinternalEvent consolenonedead
POST/v1/admin/events/test-eventinternalEvent console (debug)nonedead
POST/v1/admin/notifications/preview-assignmentinternalNotification previewnone; ingress prefix goes to notification-workerdead
GET/v1/admin/notifications/preferences/{employee_id}internalEmployee preferencesnone; ingress prefix goes to notification-workerdead
PUT/v1/admin/notifications/preferences/{employee_id}internalEmployee preferencesnone; ingress prefix goes to notification-workerdead
GET/v1/admin/notifications/workers/statusinternalWorker probenone; ingress prefix goes to notification-workerdead
GET/v1/admin/progress/assignments/{assignment_id}/summaryadmin JWT · analytics.readProgress read-outsno ingress rule; only scripts/smoke_test_*.pysuspect
GET/v1/admin/progress/assignments/{assignment_id}/usersadmin JWT · analytics.readProgress read-outsno ingress rule; only smoke scriptssuspect
GET/v1/admin/progress/assignments/{assignment_id}/analyticsadmin JWT · analytics.readProgress read-outsno ingress rule; no callersuspect
POST/v1/admin/progress/assignments/{assignment_id}/syncadmin JWT · assignments.writeManual progress re-syncno ingress rule; only smoke scriptssuspect
POST/v1/admin/progress/assignments/{assignment_id}/reportsadmin JWT · analytics.readCompletion reportno ingress rule; no callersuspect
GET/v1/admin/progress/reportsadmin JWT · analytics.readCompletion reportno ingress rule; no callersuspect
GET/v1/admin/progress/reports/{report_id}/downloadadmin JWT · analytics.readCompletion reportno ingress rule; no callersuspect
GET/v1/admin/progress/users/{user_id}/assignmentsadmin JWT · analytics.readProgress read-outsno ingress rule; only smoke scriptssuspect
GET/v1/admin/progress/users/{user_id}/historyadmin JWT · analytics.readProgress read-outsno ingress rule; no callersuspect
POST/v1/admin/progress/sync/alladmin JWT · assignments.writeManual progress re-syncno ingress rule; no callersuspect
GET/v1/admin/progress/worker/statusadmin JWT · analytics.readWorker probeno ingress rule; no callersuspect
POST/v1/admin/progress/test-eventadmin JWT · assignments.writeDebug: forge a completionno ingress rule; only smoke scripts. Known cross-tenant write (AS-A2)suspect
POST/v1/assignmentsadmin JWT · assignments.writedup — /v1 mountnone; /v1/assignments is not ingressed to this servicedead
GET/v1/assignmentsadmin JWT · analytics.readdup — /v1 mountnonedead
GET/v1/assignments/statsadmin JWT · analytics.readdup — /v1 mountnonedead
GET/v1/assignments/{assignment_id}admin JWT · analytics.readdup — /v1 mountnonedead
PATCH/v1/assignments/{assignment_id}admin JWT · assignments.writedup — /v1 mountnonedead
DELETE/v1/assignments/{assignment_id}admin JWT · assignments.writedup — /v1 mountnonedead
GET/v1/assignments/{assignment_id}/assigneesadmin JWT · analytics.readdup — /v1 mountnonedead
GET/v1/assignments/{assignment_id}/activityadmin JWT · analytics.readdup — /v1 mountnonedead
GET/v1/assignments/{assignment_id}/notificationsadmin JWT · analytics.readdup — /v1 mountnonedead
POST/v1/assignments/{assignment_id}/remindersadmin JWT · assignments.writedup — /v1 mountnonedead
GET/v1/employee/assignmentsemployee JWTMy assignmentsmicro-learning-service-v2 (app/clients/assignment_service.py:31)live
POST/v1/internal/groups/active-countinternalGroup delete guardusers-auth-service (app/routes/groups.py:243)live
GET/v1/internal/users/{user_id}/active-assignmentsinternalPush gatingnotification-worker (app/services/assignment_client.py:63)live
GET/v1/internal/users/{user_id}/assignmentsinternalusers-auth user pageusers-auth-service (app/clients/training_clients.py:120)live
GET/v1/internal/users/{user_id}/activityinternalusers-auth user pageusers-auth-service (app/clients/training_clients.py:134)live
GET/v1/internal/assignments/overviewinternalmicro-learning admin overviewmicro-learning-service-v2 (app/clients/assignment_service.py:132)live
GET/v1/internal/assignments/{assignment_id}/expansion-statusinternalRollout progress indicatormicro-learning-service-v2 (app/clients/assignment_service.py:87)live
GET/v1/internal/modules/{module_id}/assignmentsinternalModule detail screenmicro-learning-service-v2 (app/clients/assignment_service.py:154)live
GET/v1/internal/modules/{module_id}/employees/{employee_id}/assignmentinternalLearner module cardmicro-learning-service-v2 (app/clients/assignment_service.py:109)live
POST/v1/internal/assignments/{assignment_id}/cancelinternalModule deleted → cancelmicro-learning-service-v2 (app/clients/assignment_service.py:203)live
GET/v1/internal/export/{user_id}internalGDPR exportusers-auth-service (app/services/gdpr_export.py:156)live
GET/v1/internal/assignments/{assignment_id}/employeesinternalManual roster readnonedead
POST/v1/internal/assignments/{assignment_id}/employees/{employee_id}internalManual roster patchnone (bug-hunt #9)dead

5. Async contracts

Everything runs on the one shared MICRO_LEARNING stream. All publishing goes through the one NATSClient.publish() helper (app/core/nats_client.py:116) and all subscribing through its subscribe() / pull_subscribe() delegates, so the subjects below are the ones the callers pass in.

Consumes

SubjectStreamDurablePublished byFeatureVerdict
training.assignments.>MICRO_LEARNINGexpansion_worker (pull, max_deliver 4, ack_wait 300s)this service (assignment.created / assignment.retargeted)Audience expansionlive
training.assignments.>MICRO_LEARNINGenhanced_event_worker (push, manual ack)this service (updated, canceled, expansion_*)Event bookkeepinglive
training.assignments.>MICRO_LEARNINGnotification_event_worker— never subscribed; the worker is not started (app/main.py:18-21)Superseded duplicatedead
training.progress.>
matches the published training.progress.assignment_progress
MICRO_LEARNINGprogress_event_worker (pull, max_deliver 4, ack_wait 300s)micro-learning-service-v2 (app/events/events.py:125, from the mobile progress path app/routes/progress_mobile_routes.py:1344)Partial-progress mirrorlive
training.module.completedMICRO_LEARNINGprogress_module_completion_worker (pull)micro-learning-service-v2 (app/events/events.py:80)Completion detectionlive

Publishes

SubjectConsumed byFeatureVerdict
training.assignments.ten.*.assignment.createdthis service's expansion_worker (app/workers/expansion_worker.py:207)Audience expansionlive
training.assignments.ten.*.assignment.retargetedthis service's expansion_worker (same handler)Audience changelive
training.assignments.ten.*.assignment.updatedthis service's enhanced_event_worker (audit row only)Event bookkeepinglive
training.assignments.ten.*.assignment.canceledthis service's enhanced_event_workerEvent bookkeepinglive
training.assignments.ten.*.assignment.expansion_completednotification-worker durable notification_worker_admin_exp_ok (app/core/config.py:123-128) → admin inboxRollout finished noticelive
training.assignments.ten.*.assignment.expansion_failednotification-worker durable notification_worker_admin_exp_fail (app/core/config.py:130-133)Rollout failed alertlive
training.assignments.ten.*.assignment.expandedonly this service's enhanced_event_worker, which records it as assignment.expansion_completed; no external consumer despite the code comment claiming notification-worker reads itEvent bookkeepinglive
training.assignments.expandednobody acts on it — published only from _process_assignment_created, a method the file labels "test helper", and swallows its own publish errorsnone (test-only path)dead
training.assignments.ten.*.progress.init_requirednobody — and publish_progress_init_required() has no call site in the servicenonedead
training.assignments.reminders.ten.* (scheduler)notification-worker durable notification_worker_reminders (app/services/notification_service.py:1274,2380)Automatic reminderslive
training.assignments.reminders.ten.* (manual sends)same durableManual reminderslive
training.assignments.escalations.ten.*notification-worker durable notification_worker_escalations (app/services/notification_service.py:1454-1467,2381)Escalationlive
training.notifications.ten.*.assignment.*notification-worker durable on training.notifications.ten.*.assignment.> (app/services/notification_service.py:1836) → FCM push + historyAssigned / completed pusheslive
training.notifications.ten.*.admin.*notification-worker durable notification_worker_admin on training.notifications.ten.*.admin.> (app/core/config.py:114-118)Admin digest, fully-completed noticelive

Known defect: all of the above use core nc.publish, not js.publish, so the publisher never sees a JetStream ack and sets no Nats-Msg-Id — the stream's 120s duplicate window cannot help (to-be-reviewed AS-A9).

Background jobs

JobScheduleWhat it doesVerdict
GroupExpansionWorker._process_messages
expansion_worker.py:150
continuous pull loop, batch 10, 5s fetch timeoutStarted by the lifespan (app/main.py:76-77). Turns group/company assignments into recipient rows and pushes each person.live
_ack_in_progress
expansion_worker.py:171
every 30s per in-flight messageStarted per message by the loop above. Extends the JetStream ack deadline so a company-wide rollout is not redelivered mid-write.live
EnhancedEventWorker._retry_failed_events_loop
enhanced_event_worker.py:41
continuous, interval from the workerStarted by EnhancedEventWorker.start(), which the lifespan calls (app/main.py:51-53). Re-processes events whose first attempt failed.live
EnhancedEventWorker._cleanup_old_events_loop
enhanced_event_worker.py:42
continuous, interval from the workerSame start path. Archives old audit/dedup rows.live
ProgressEventWorker._process_module_completion_messages
progress_event_worker.py:144
continuous pull loopStarted by the lifespan (app/main.py:61-64). The real completion path: drives assignments to 100% and fires the completion push.live
ProgressEventWorker._periodic_sync_loop
progress_event_worker.py:53
every 3600s (hourly), for assignments from the last 7 daysSame start path — and it starts even if the NATS subscription failed ("degraded, sync-only" mode). Re-reads progress from micro-learning so a lost event self-heals.live
ProgressEventWorker._process_progress_messages
progress_event_worker.py:119
continuous pull loopStarted by the lifespan (app/main.py:61-64). Drains training.progress.> and routes on the last subject token: assignment_progress is the live partial-progress mirror (and commits, :315); the lesson_completed and assignment_completed branches have no publisher (§8).live
ReminderSchedulerWorker._loop
reminder_scheduler_worker.py:34
every REMINDER_SCHEDULER_TICK_SECONDS (default 300s); skipped entirely if REMINDER_SCHEDULER_ENABLED=falseStarted by the lifespan (app/main.py:56-58). Three sweeps per tick: reminder cadence + escalation, start-day announcements, due admin-scheduled reminders.live
AdminDigestWorker._loop
admin_digest_worker.py:90
wakes every ADMIN_DIGEST_CHECK_SECONDS (default 60s), fires once per UTC day at hour ADMIN_OVERDUE_DIGEST_HOUR_UTC (default 07); skipped if ADMIN_DIGEST_ENABLED=falseStarted by the lifespan (app/main.py:67-70). One overdue digest per tenant with anything overdue.live
NotificationEventWorker (whole worker)
notification_event_worker.py:61
never runsNot started. The lifespan import was removed on purpose; starting it beside EnhancedEventWorker would double-publish every created notification.dead

There are no k8s CronJobs for this service — every schedule above lives inside the single API pod (replicas: 1), so a rolling restart pauses reminders and the digest for the duration of the roll.

6. Data it owns

One logical Postgres DB, assignments, on the shared instance. 15 alembic revisions; head adds performance indexes (2026_08_30_add_perf_indexes.py). ARCHITECTURE.md §3.4 still lists a second writer, assignment-expansion-worker — that is stale: the merge landed (§6 M1, PR #501) and the repo directory now holds no source, so assignment-service is the only writer.

TableWhat it holdsWritten by
assignmentsOne row per "this module is assigned to this audience": module id + version snapshot, type, start/due date, priority, required flag, quiz overrides, notification config, status, expansion status, cancellation and the fully_completed_notified_at / start_notified_at claim stampsassignment routes, expansion worker, progress worker, reminder scheduler
assignment_targetsThe audience as the admin described it (employee ids, group ids, or "company")assignment routes
assignment_recipientsThe audience resolved to people — one row per employee. This is what reminders, aggregates and the mobile list readassignment routes (individual targets), expansion worker (group/company), internal add-employee route
assignment_progressPer person per assignment: status, percentage, time spent, started/completed timestampsprogress worker, progress routes
progress_milestones25/50/75/100% markers, with the notification_sent flag that keeps the completion push singleprogress worker
progress_eventsRaw progress event historyprogress worker
completion_reportsGenerated JSON reports with an expiry and a download counterprogress routes
assignment_statisticsCached per-assignment aggregatesprogress worker / progress routes
assignment_remindersPer person per assignment reminder state: remind count, last reminded, resolved timezone, escalated flagreminder scheduler
scheduled_remindersAdmin-scheduled manual reminders: audience, message, send time, status, recipient countmanual reminder route, reminder scheduler
assignment_eventsThe audit trail behind the admin activity feed and the notification logenhanced event worker, reminder scheduler, manual reminder service
event_deduplication, event_processing_logs, event_dead_lettersEvent pipeline bookkeeping: redelivery suppression, per-attempt logs, poison eventsenhanced event worker (the admin API over these tables is dead — §8)

Tables this service writes in someone else's database: none. Tables in assignments written by anyone else: none.

7. Dependencies

flowchart LR
  ML["micro-learning-service-v2"] --> AS["assignment-service"]
  UA["users-auth-service"] --> AS
  NW["notification-worker"] --> AS
  AS --> UA
  AS --> ML
  AS --> NW
  AS -.-> JS["MICRO_LEARNING stream"]
  JS -.-> NW
  JS -.-> AS

Inbound HTTP: micro-learning (H13, mobile read path, 8 call sites), users-auth (H20, plus the GDPR CronJob H23), notification-worker (H26). Outbound HTTP: users-auth (H15 — groups, members, profiles, timezone), micro-learning (H16/H17 — progress and module validation), notification-worker (H18 — device check). Async both ways over the one shared stream. Numbering per ARCHITECTURE.md §3.2.

8. Dead-code verdicts

Every entry point with no in-repo caller. Deleting is a separate decision — see the hub roll-up.

Entry pointKindVerdictEvidence
The 10 /v1/assignments… pathsHTTP (duplicate mount)dead(1) no caller: repo-wide search for /v1/assignments finds only this service's own outbound client for micro-learning's …/progress-summary (app/services/progress_service.py:102) and the scan JSON. (2) unreachable: the only ingress routes /v1/admin/assignments to this service and everything else to the users-auth catch-all (global-configs/k8s/users-auth-ingress.yaml:76-82,229-236), and no in-cluster service calls these paths. Same handlers are live under /v1/admin. Deleting the /v1 mount (app/main.py:164) would also remove /v1/employee/assignments, which IS live — split the router instead.
GET /v1/admin/employee/assignmentsHTTP (duplicate mount)dead(1) no caller: micro-learning calls the /v1/employee/assignments form (micro-learning-service-v2/app/clients/assignment_service.py:31); nothing references the /v1/admin/employee form. (2) unreachable: the ingress prefix is /v1/admin/assignments, which does not match /v1/admin/employee/…, so external calls fall through to users-auth and 404. Artifact of app/main.py:158.
The 9 /v1/admin/events… routesHTTP (internal-only)dead(1) no caller: every handler takes Depends(validate_internal_token) (app/routes/event_routes.py:28,66,126,192,249,289,313,336,374) and no service in the repo calls any of these paths. (2) unreachable as an admin surface: no ingress rule for /v1/admin/events. GET /v1/admin/events/dead-letters is additionally unreachable in-process — /events/{event_id} is registered first and turns it into a 400 (bug-hunt #20).
The 4 /v1/admin/notifications… routesHTTP (internal-only)dead(1) no caller: internal-token routes (app/routes/notification_routes.py:25,93,129,163) with no in-repo caller. (2) unreachable: the ingress sends the whole /v1/admin/notifications prefix to notification-worker-service (global-configs/k8s/users-auth-ingress.yaml:142-149), so an external caller never arrives here. The preferences pair would also fail open against a non-existent notifications-service host (to-be-reviewed AS-A13).
GET /v1/internal/assignments/{assignment_id}/employeesHTTP (internal-only)dead(1) no caller: searched every service's client layer and the path string repo-wide; micro-learning's client calls expansion-status / module assignments / cancel / overview / per-employee module assignment only (micro-learning-service-v2/app/clients/assignment_service.py:87-203). (2) internal-only surface (app/routes/internal.py:182), not exposed by any ingress rule.
POST /v1/internal/assignments/{assignment_id}/employees/{employee_id}HTTP (internal-only)dead(1) no caller: same search; bug-hunt #9 independently states "no live inter-service caller of this POST was found". (2) internal-only surface (app/routes/internal.py:249), no ingress rule. Its missing commit was fixed, so it would now work if anyone called it.
The 12 /v1/admin/progress… routesHTTP (admin)suspectSearched: the whole repo for each path string, every service's client layer, and every docs/*.md / FE integration note. Only hits are this service's own scripts/smoke_test_*.py (not production callers) and tests. The admin web app is not in this repo, so this stays suspect — but note the ingress has no /v1/admin/progress rule (global-configs/k8s/users-auth-ingress.yaml), so it cannot be reached from admin.useoper.com today. Resolve by checking the admin app's network calls, or add the ingress rule if the screens exist.
GET /v1/admin/assignments/statsHTTP (admin)suspectSearched the repo for the path and for get_assignment_stats: only tests (tests/test_assignment_list_stats.py:457, tests/test_admin_scope_matrix.py:82). It IS inside the ingressed /v1/admin/assignments prefix and is the obvious source for the dashboard tiles, but no doc names it. Resolve by checking the admin app.
NotificationEventWorker + NotificationEventWorkerManager
app/workers/notification_event_worker.py (243 lines)
NATS consumer + moduledead(1) Never started: app/main.py:18-21 carries the explicit comment that the import was removed, and the lifespan (app/main.py:45-89) constructs only EnhancedEventWorker, ReminderSchedulerWorker, ProgressEventWorker, AdminDigestWorker and GroupExpansionWorker. Repo-wide, the only other reference is tests/test_notifications.py:14. (2) Must not be started: it subscribes the same training.assignments.> under its own durable notification_event_worker (:61), so it would receive its own copy of every created event and re-send the "New training assigned" push that the expansion worker already sent — double-publishing. Cross-referenced: to-be-reviewed A4/A12; ARCHITECTURE.md §3.3 ("deliberately-disabled notification_event_worker").
Handlers _handle_lesson_completion_event and _handle_completion_event (the lesson_completed / assignment_completed branches of the training.progress.> router)NATS handlersdead(1) Reachable only when the subject's last token is lesson_completed or assignment_completed (app/workers/progress_event_worker.py:188-193). (2) No publisher: the only training.progress.* subject anyone emits is training.progress.assignment_progress (micro-learning-service-v2/app/events/events.py:125) — grepped every training.progress. occurrence repo-wide; the rest are stream-subject config and docs. micro-learning's lesson event is learning.lesson.completed, a different subject tree, and completion arrives as training.module.completed on the separate durable. NOTE: the consumer itself and _process_progress_messages are live — they carry the partial-progress mirror (§3, §5). bug-hunt #8 / to-be-reviewed AS-A10 and the worker's own comment at :127 call the whole subscription producerless; that is stale, only these two branches are.
Publisher training.assignments.expandedNATS publishdead(1) Its only call site is inside _process_assignment_created (app/workers/expansion_worker.py:359), a method whose own docstring says "Test helper" and which swallows publish errors "to keep tests focused"; the live path is _handle_assignment_created_expand_assignment (:217,367), which publishes the ten.* subjects instead. (2) No consumer filter targets the flat 3-token subject: notification-worker's assignment bridges filter training.assignments.ten.*.assignment.expansion_completed / …expansion_failed (notification-worker/app/core/config.py:123-133).
Publisher method publish_progress_init_requiredtraining.assignments.ten.*.progress.init_requiredNATS publishdead(1) No call site: searched app/ for publish_progress_init_required — only the definition (app/core/nats_client.py:318). (2) No consumer: nothing outside this service subscribes anything under training.assignments.>, and the in-pod consumers key on the assignment.* token. Documented as a real event in docs/streams.md:222, which is stale.
AssignmentService.validate_assignment_targetsunreachable functiondeadOnly callers are unit tests (tests/test_assignment_service.py); the live expansion path uses expansion_worker._expand_targets. Its company branch references an undefined jwt_token, so it would raise NameError immediately if wired up — bug-hunt #17 / to-be-reviewed AS-A4 (app/services/assignment_service.py:885).
WorkerManager in app/workers/expansion_worker.py:696unreachable classdeadThe lifespan constructs GroupExpansionWorker() directly (app/main.py:76); no code path instantiates this manager wrapper. Searched the repo for WorkerManager outside the file.
app/services/notifications_service.py (the NotificationsServiceClient preferences client)unreachable dependencyunknownIts only users are the dead /v1/admin/notifications/preferences/* routes and an unused injection in AssignmentProgressService.__init__; it targets http://notifications-service:8000/v1/preferences/…, a host that does not exist, and every call fails open to defaults (to-be-reviewed AS-A13/AS-A20). Marked unknown rather than dead because whether created/cancelled pushes are supposed to be preference-gated is a product question: if yes, this is a broken live feature to repoint at users-auth; if no, the client and the routes go. Resolve with the notification-preferences owner.

9. Sources