# OSCAR knowledge graph

**OSCAR (Online Scoring and Reporting)** is where open-ended student responses get scored. The testing platform (mainly AD, also PAN/PASS, Cognia and other hosts) pushes responses in. Human scorers score them, and AI engines (mainly Pearson IEA, plus CRASE, EMMA and Writeshift) can score them too. Every score is checked by QC: training, qualification, validity, calibration, trend and second reads with resolution. Supervisors run reports, payroll and scheduling. Final scores go back to the host through a callback. The app is one Go monolith (gin, `main.go` + `src/`) and a Vue 3 SPA (`client/`) on a single MongoDB, with Valkey as a cache.

Repo: `pearson-aq/oscar`. Graph: `nodes/*.md` (schema in `skills/kg-build/references/schema.md`), permission catalog in [permissions.md](permissions.md).

## Start here

1. [[oscar-server]]: the binary: startup order, middleware, `SERVER_MODE`, how APIs are bound.
2. [[oscar-mongo]] and [[tenant]]: one database for all tenants, separated only by `client_id`.
3. [[response]]: the central record. It holds embedded scores, flags, state and export state. Read [[response-state]] and [[score-type]] alongside it.
4. [[item]], [[section]], [[project]] (see [[project-section-item]]): all the scoring rules live on the item.
5. [[score-response]]: get-next → submit → resolution. This is the core scorer loop.
6. [[external-response-import]]: the host round trip, split into [[ingest-response]] and [[export-scores]].
7. [[qc-set]] and [[qc-type]]: the QC model behind qualification, validity, calibration and trend.
8. [[role]], [[auth]] and [permissions.md](permissions.md): there is no permission middleware. Every handler checks `user.Role.X` itself, and many don't.
9. [[async-jobs]]: the background sweepers that move responses between states.
10. [[iea-scoring]]: the main AI path.

## Main flows

| area | flows |
|---|---|
| Sign-in | [[password-login]], [[sso-login-flow]], [[login-as]] |
| Responses in and scores out | [[external-response-import]] (overview), [[ingest-response]], [[export-scores]], [[appeal]] |
| Scoring | [[score-response]], [[rescore]], [[score-reset]], [[uk-allocation]] |
| QC | [[training-gate]], [[qualification-gate]], [[validity-check]], [[interspersed-trend]], [[irr-rebuild]], [[public-qc-set-link]] |
| AI | [[iea-scoring]], [[iea-tod-modeling]], [[ai-engine-sweep-scoring]], [[copyleaks-plagiarism-scan]] |
| Ops and admin | [[scorer-pay]], [[observation-exam]], [[schedule-change-emails]], [[config-sheet-bulk-edit]], [[tenant-import-export]], [[peacs-import]] |

Product features are listed as `capability` nodes, for example [[human-scoring]], [[ai-scoring]] and [[score-delivery]]. The glossary is in the `concept` nodes. Watch the overloaded terms: [[queue]], [[admin]], [[reliability]], [[cf]] (IEA = CF = KT), [[pv-mode]].

## Module layout

- **Layers.** `src/apis` holds the gin handlers, one `*API` struct per file. Each struct has `RegisterAll`, gets appended in `src/apis/init.go` and is bound by `apis.BindAll` in `main.go`. Handlers call `src/services` (business logic, one service per area, some in subpackages such as `services/scoring` and `services/qc`). Services use `src/models` (≈230 structs, generic Mongo helpers `models.Lookup/LookupMany/Upsert…`, indexes in `src/models/connect.go`) and `src/db_utils`. Machine-to-machine routes live in `src/external` ([[external-api]], [[iea-callback-api]]).
- **One database.** Everything is in [[oscar-mongo]] (`<ENV>-<CENTRAL_DB_NAME>`). Tenants ([[client]]) are separated only by `client_id`, and isolation is enforced by hand in each query. Machine routes check it with `utils.CanAccessClient`. Every missing filter is a cross-tenant bug (see Known risks).
- **Valkey** ([[valkey]]) is a read-through cache for clients, sections, items, rubrics and QC stats, and handles pub/sub between instances. The Valkey work-queue backend is switched off in code (`useValkeyBackend` returns false).
- **Async sweepers** ([[async-jobs]]) are started by `async.Start()`. They are goroutine loops and cron jobs, each guarded by [[scan-locker]] so only one instance runs a sweep. Examples: response ingest, auto-deliver, outbound scores, IEA/CRASE/EMMA/Writeshift, appeals, allocation, trend, compensation, PEACS and scheduled reports. Each can be switched off with `property_values` flags or `SUPPRESS_BACKGROUND_PROCESSES`.
- **`SERVER_MODE`.** Empty or `frontend` serves the SPA and the API. `backend` serves the API only. `watcher` runs only the [[irr-watcher]] change stream. `DBUPDATE_FLAG` runs indexes and [[migrations]] once, then exits.
- **Client.** [[oscar-client]] (Vue 3). Routes are gated by [[client-route-guard]], each `client/src/services/*Service.js` wraps one API ([[client-api-services]]), and the response viewer runs per-mode [[scoring-drivers]].
- **Config.** All settings live in `src/options/options.go`, split by owner into [[oscar-env]], [[integration-env]], [[ops-integration-config]] and [[reporting-env]]. Per-item settings are in [[item-engine-config]] and [[item-qc-config]].

## Conventions (from `.agent/CONVENTIONS.md`, `.agent/WORKFLOWS.md`)

- **New API route.** Add or extend the model in `src/models` (bson tags) and the data access in `src/db_utils`/models helpers, then the service logic in `src/services` with a `*_test.go`. Add the handler to an `*API` struct in `src/apis` and register it in that struct's `RegisterAll` under the right group (`AuthRequired`, or `authorize.AuthorizeRequest` for machine routes). WORKFLOWS.md says "register in main.go", but in practice routes are registered through `src/apis/init.go`. **Add the role check inside the handler**, because nothing does it for you. Filter every query by the caller's `client_id`. Add an integration test in `TestAutomation/API/`.
- **Migrations** are data-only. Add a file in `src/migrations` that registers a `Migration` (`Name`, `Idempotent`, `NeedsToRun`, `Run`) in `init()`. It runs at deploy with `DBUPDATE_FLAG`, and on every boot when `ENV=dev2`. Results are recorded in `migration_records`. You can dry-run it with the [[oscar-migrate-cli]]. Indexes go in `src/models/connect.go` (`EnsureIndexes`).
- **Tests.** `./run_unit_tests.sh` (no config). Target a suite with `./run_tests.sh --levels unit --skip-requires all --only <suite>`. Frontend: `npm test` and `npm run lint` in `client/`.
- **Go style.** `snake_case` file names, short receivers, a doc comment on every exported symbol, and wrapped errors (`fmt.Errorf("…: %w", err)`). Order struct fields for `fieldalignment`. Functional helpers (`utils.RPipe*`, fp-go) are allowed where the code already uses them.
- **Vue style.** `PascalCase.vue` multi-word components, typed props and kebab-case events. Use `<style scoped>`. Mutate reactive object props directly; don't use the clone, watch and emit pattern.

## Known risks

Every item below was checked in the code at the path given. Severity is our judgement. Secret values are not reproduced here.

### Critical

1. **Production database credentials in source.** `src/simulation/main.go:21-23` holds the Atlas Mongo connection string (user `oscar_pt`) in a live `os.Setenv`, and RDS MySQL credentials appear in comments at `src/simulation/main.go:11`, `src/simulation/exp_test.go:45` and `src/simulation/simulation.go:32`. Rotate these and purge them from git history. See [[simulation-tool]].
2. **Hard-coded session cookie secret** at `main.go:208`. Anyone with the repo can forge the `oscar` cookie ([[oscar-server]], [[auth]]).
3. **Hard-coded secret defaults.** `src/options/options.go:130-131, 153, 196-197` sets defaults for SECURE_STORAGE_KEY/IV (credential encryption), the Learnosity key/secret and the Copyleaks webhook token. They apply whenever the env var is unset.
4. **Hard-coded Postmark server token** in `src/postmark/postmark.go:212-215`, used in production by `src/services/reporting/colorado_score_splits.go:66` ([[postmark]]).
5. **Default admin seed.** When `users` is empty, every environment seeds `admin@mzscore.com` with a hard-coded bcrypt hash and the LegacyAdmin role: `src/db_utils/init_data.go:18-33, 124-136`, called from `main.go:115` ([[init-data]]).
6. **SAML dev bypass.** `ENV` defaults to `dev2` (`src/options/options.go:56`). That default, or the `-prod`, `-staging` or `-uk` flags, sets `SAML_DEV_BYPASS` (`main.go:97-100`). With `SAML_DEV_BYPASS_EMAIL` set, `GET /identity/saml` logs anyone in as that user (`src/apis/saml_api.go:135-157`). See [[sso-login-flow]].
7. **Cross-tenant writes.** Shared DB, missing `client_id` checks:
   - `PostScore`/`PostScores`, `PostQcResponse`, `Appeal` and `ChangeItemReliabilityPercent` trust the tenant in the body and never call `CanAccessClient` (`src/external/external_api.go:1813-1860, 2332-2545, 5801-5835, 7497-7550`; [[external-api]]).
   - `POST /v1/admin/qc_sets` has no role check and upserts by body `_id`, which lets a user take over another tenant's QC set (`src/apis/qc_api.go:79`, `src/services/qc/qc_service_crud.go:960-969`).
   - `BulkUpdateItems` updates by `_id` with no client filter and can `$set` `client_id` (`src/apis/item_api.go:593-713`; [[config-sheet-bulk-edit]]).
   - `POST /v1/legacy/submit_rescore` → `ApplyRescore` overwrites any score by response id. It checks no rescore request, no ownership and no tenant (`src/services/scoring/submit.go:411-470`; [[rescore]]).
   - Observation `CompleteSession` loads the session by id (`src/apis/observation_api.go:734`).
8. **SSRF and XSS.** `GET /v1/reframe?url=` fetches any URL server-side for any logged-in user and renders the body as HTML (`src/apis/media_api.go:68, 1372-1405`; [[media-api]]).
9. **Importer-controlled `callback_url`.** The callback URL replaces the export endpoint. For Pearson-type exports the OAuth host is derived from it, so the tenant's export key and secret go to a host the importer chose (`src/external/outbound_driver.go:71-83, 130-137`; [[export-scores]]).
10. **Unauthenticated routes.** These need no login:
    - Loopback receivers `/v1/api/score`, `/scores`, `/pan/score` and `/obs_score` write api-log rows with the tenant from the body (`src/external/external_api.go:132-135, 2766-2790`).
    - The Copyleaks status webhook marks any response's scan failed, and `/v1/api/copyleaks_report/complete/:scanID` takes no auth (`src/services/copyleaks/copyleaks_api.go:90-123`).
    - `/v1/testing/scorer` and `/unclaim` (`src/apis/perf_testing_api.go:25-28`) and `/v1/assess` (`src/apis/learnosity_api.go:28-30`).
    - `/v1/delayed_file/:ext` when `ENV=dev2`, which is the default (`src/apis/image_api.go:44-46`).
    - Anonymous meeting feedback (`src/apis/video_meeting_api.go:76, 2059-2070`).

    The full list is in permissions.md.
11. **Any user can export all scores.** `/v1/admin/export/scores`, `/all_scores_by_rubric_config`, `/audit` and `/qc_sets/...` have no role check (`src/apis/export_api.go:45-49`; [[export-api]]).
12. **Payroll time injection.** `POST /v1/compensation/update_queue_time` takes `user_id` and the time from the body, so any user can credit anyone with paid time (`src/apis/scoring_api.go:95, 1800-1820`; [[scorer-pay]]).
13. **Training self-certification.** `POST /v1/my/training/course/:c/element/:e/progress/2` marks any element complete for the caller, which opens the training gate to scoring (`src/apis/training_api.go:46, 294-323`; `src/services/training_service.go:1277`; [[training-gate]]).

### High

1. **Browser hardening.** CORS allows all origins with credentials, and CSP and frame-deny are commented out (`main.go:168-207`).
2. **QC bypass.** Any scorer can add `?skipqc=true` to get-next to skip qualification, trend and validity (`src/apis/scoring_api.go:144-188`; [[validity-check]]).
3. **Privilege escalation through tenant import.** Imported users keep `superuser`/`superadmin` from the manifest, so a `page_config_tenants` holder can create platform superusers (`src/services/importexport_service.go:1519-1544`). Tenant exports also contain credential secrets in plain text ([[tenant-import-export]]).
4. **Public QC links.**
   - Any logged-in user can publish any QC set by id. There is no role or tenant check, and no `client_id` is stored.
   - `GET /v1/external_link_data/:id` needs no auth.
   - Link ids come from `math/rand`.
   - The IP limit trusts `X-Forwarded-For`.

   Paths: `src/apis/external_link_api.go:31-200`, `src/utils/rand.go:13`, `src/utils/remote_ip.go:29`; see [[public-qc-set-link]].
5. **Logged-in routes with no role checks.**
   - Response upload `/v1/admin/upload/*` (`src/apis/response_load_api.go:29-32`).
   - Admin schedules, where `SaveSchedules` upserts by body `_id` and so can overwrite another user's schedule (`src/apis/schedule_api.go:29-38`, `src/services/schedule_service.go:167`).
   - `POST /v1/admin/iea/send_qc_true_scores`, which takes `client_id` from the body (`src/apis/iea_api.go:59, 70-81`).
   - Item resources ([[resource-api]]): any user can upload or remove them, and items are loaded by `_id` across tenants (`src/apis/resource_api.go`).
6. **Trend m-session reopen and close** update `qc_sets` by `_id` with no client filter (`src/apis/trend_api.go:220-262`).
7. **Weak secrets handling.**
   - Password-reset hashes use bcrypt cost 4 (`src/apis/auth_api.go:668`).
   - Tokens and passwords come from time-seeded `math/rand` (`src/utils/rand.go:17-43`).
   - External Basic auth compares the plaintext password in a Mongo query (`src/authorize/authorize.go:156`).
   - reCAPTCHA result is ignored, and `"local"` skips it (`src/services/user_service.go:377-388`).
8. **Secrets stored in plaintext logs.** Score-export messages keep the full Authorization header (`src/external/outbound_driver.go:175`, `src/models/models.go:7556, 7572`). It is masked only when the API logs screen reads it.
9. **Committed third-party credentials.**
   - e-rater Basic auth credentials (`src/services/erater/erater_driver.go:43-44`; dead code).
   - AMI API key (`src/services/ami/curl.sh:4`).
10. **Video meetings.** The guest password is stored in plaintext and matched in the query. A guest seat can be taken over by display name. Socket.IO allows any origin (`src/models/models.go:8592-8594`, `src/apis/video_meeting_api.go:84-125`, `src/apis/video_meeting_api_ws.go:50`).

### Medium

1. **TLS checks off.** `InsecureSkipVerify: true` is set on the outbound score drivers, PAN, PEACS, Splunk, CRASE, EMMA, RMS and e-rater (for example `src/external/outbound_driver.go:29`, `src/services/peacs_service.go:31`). ACT SFTP uses `ssh.InsecureIgnoreHostKey()` (`src/services/sftp/sftp_service.go:43`).
2. **Any database by header.** The `X-Mongo-Connection` / `X-Mongo-Database` headers point a request at any Mongo URI. The handler requires superuser, but the server will still connect anywhere (`src/apis/common.go:201-219`).
3. **Cross-tenant read of session history.** `GET /v1/activity/user/:userID` is not tenant-scoped (`src/services/activity_service.go:60-75`; [[user-session-log]]).
4. **Incentives cross-tenant.** Upsert trusts the body `client_id`, and delete is by `_id` (`src/services/incentives_service.go:52-56`, `src/apis/incentives_api.go:76`; [[incentive-api]]).
5. **State-changing GETs with no role check.** The EMMA admin actions (`src/apis/emma_api.go:31-38`). The API logs screens have no server-side check either (`src/apis/api_logs_api.go:30-32`).
6. **MFA and session gaps.**
   - Login creates the session before the MFA and archived checks.
   - `/v1/mfa_required?email=` tells anyone whether an account exists and needs MFA (`src/services/user_service.go:102-129`, `src/apis/auth_api.go:117-150, 743-775`).
   - The SSO hand-off cookie is set without HttpOnly or Secure (`src/apis/saml_api.go:189`).
   - The IP-change session-hijack check is commented out (`src/services/user_service.go:570-590`).
7. **Image upload accepts any extension** and derives the MIME type from it (`src/apis/image_api.go:63-84`).
8. **Committed or low-grade keys.**
   - SpreadJS licence keys in `client/src/services/SpreadJSKey.js:11-30`.
   - EMMA key hard-coded to `testclient` (`src/services/emma/emma_driver.go:111`).
   - The previewer secret is sent in the query string (`src/services/previewer_service.go:46`).
   - Response-processing job routes take their access token in `?token=` (`src/apis/アadmin_tasks_api.go:88-92`).
9. **Hard-coded hosts and recipients.** A Cognia UAT host (`client/src/components/viewer/CogniaMedia.vue:43`) and personal email recipients (`src/services/reporting/backread_reporting.go:105`, `src/async/aging_reports/pte_report.go:28-29`).
10. **Score reset beyond ownership.** `reset_own_scores` lets the holder reset any score in the tenant (`src/apis/auditing_api.go:1210-1240`).
11. **Booking stored before the tenant check.** `PostBooking` writes the raw booking JSON before checking the tenant (`src/external/external_api.go:4715`).
12. **No HTTP timeouts on AI engine clients**: CRASE, Writeshift and EMMA (`src/services/crase/crase_driver.go:78-81`, `src/services/writeshift/writeshift_driver.go:39`).

## Notable bugs

- **Outbound queue.**
  - `RecordError` nests `$addToSet` inside `$set`, so per-message errors are never written (`src/respqueue/queue.go:341-344`).
  - The `outbound_queue` lock index is on `locked_at`, but the code queries `lock_time` (`src/models/connect.go:463`).
- **Auto-deliver.** The section lookup appends only ids that are already in the list (inverted `utils.Contains`, `src/async/async.go:3125-3131`), so `do_not_export` responses are re-selected every 6 s ([[export-scores]]).
- **Locks.**
  - `lock_hours=0` with lock-through-logout gives a 5-second lock (`src/models/models.go:2334-2337`).
  - `keepLocked` is always false, because `/v1/legacy/submit` has no `:keep` param (`src/apis/scoring_api.go:53, 709`).
  - Interspersed-trend locks never expire.
- **Appeals.** Appeal requests in ERROR are retried forever (`src/async/async.go:7227-7228`).
- **PEACS.**
  - `peacs_jobs` has no unique index, so a job is inserted every minute (`src/async/async.go:5785, 5820-5827`).
  - `foundClient` is never reset, so later applicants land in the first matched tenant (`src/services/user_service.go:2083-2101`).
- **IEA.**
  - `CreateModelingFiles` writes to `iea_tod_models` instead of `iea_tod_items` (`src/apis/iea_api.go:617`).
  - `GetSimulatedPayload` does not return after a 404 (`src/apis/iea_api.go:1327-1328`).
  - The `BufferedScoreUpdate` sweepers are commented out (`src/async/async.go:127-128`).
- **Compensation.**
  - The unique index names a non-existent `score_date` field, and `compensation_jobs_singapore` has no index (`src/models/connect.go:647`).
  - A failed S3 upload still marks the job complete (`src/services/compensation/compensation_service.go:1601`).
- **Tenant copy.**
  - Full copy rejects every valid ref id because the check is inverted (`src/apis/client_api.go:2613-2616`).
  - `DoEnvironmentConversions` is empty (`src/services/importexport_service.go:1204`).
  - Import errors are swallowed (`:930-933`).
- **Score reset.** A crashed reset job stays RUNNING forever (`src/services/scoring/reset.go:649-651`). The config-sheet "backup" is saved after the update (`src/apis/item_api.go:697`).
- **Schedules.**
  - `SaveSchedules` checks a stale `err` (`src/services/schedule_service.go:161-166`).
  - `RescheduleBooking` deletes and then inserts with no transaction, so a failed insert loses the booking.
- **IRR watcher.** `time.Sleep(5000)` is 5 µs, and there is no lock or resume token ([[irr-watcher]]).
- **CRASE.** `handleRecoverableCRASEError` calls `.Error()` on a nil error and panics (`src/services/crase/crase_service.go:220-229`).
- **Login-as.** With `?force`, it logs the target out and returns 200 without logging the caller in (`src/apis/auth_api.go:225-238`).
