# AQ School — system graph

Thirteen app graphs linked into one system: **ad** (Authoring & Delivery — items, forms, the test player, scoring),
**abigail** (item bank / review front end over AD), **adam** (assessment administration and delivery platform),
**arc** and **navvy** (classroom apps that launch AD tests and receive scores), **lp** (Launchpad — OneRoster
rostering and SSO hub), **cow** (the data warehouse / extract platform fed from ADAM, and soon Navvy),
**oscar** (constructed-response scoring: human scorers, QC, AI engines), **cf** (Continuous Flow, the AI scoring engine OSCAR calls
IEA; only its External Client API is mapped), **mre** (Math Response Engine, AD's math scorer), **xprt** (print-PDF composition on
Typefi), **mot** (Materials Ordering and Tracking for paper tests) and **psaes** (paper scan-program and SRL layout setup).
Links live as `xrefs` in app nodes (declared from the dependent side) and as system nodes under `nodes/`;
[INDEX.md](INDEX.md) is generated by `kg.mjs system` and lists every link per pair.

## Start here
- Hub: AD has no runtime dependency on any other app. Four hosts depend on it — [[abigail:ad-data-api]],
  [[adam:ad-platform]], [[arc:ad-api]], [[navvy:adam-ad]] — through the shared contracts
  [[system:ad-data-api-oauth]], [[system:ad-register-session]], [[system:ad-embedded-player]] and [[system:ad-host-callback]].
- Rostering/SSO: ADAM, Navvy and COW's forensics dashboard read [[system:lp-oneroster-api]] and log in via
  [[system:lp-oidc-sso]]; only ADAM consumes [[system:lp-roster-change-webhook]].
- Data out: COW reads ADAM's Postgres through [[system:adam-cow-read-replica]] ([[system:ingest-adam-to-warehouse]]) and
  hands files back via [[system:cow-report-asset-manifest]].
- Scoring services behind AD: constructed responses go to OSCAR ([[system:oscar-external-api]] in,
  [[system:oscar-score-callback]] out; flow [[system:score-constructed-response-with-oscar]]), which uses CF as its AI engine;
  math goes to MRE ([[system:score-math-response-with-mre]]). ADAM also talks to OSCAR directly.
- Paper: AD prints through XPRT ([[system:print-form-via-xprt]]); ADAM orders materials from MOT
  ([[system:materials-ordering-from-adam]]); PSAES shares customer and admin codes with ADAM and has a planned lookup
  ([[system:adam-psaes-lookup-api]]).
- Leaf apps: mot, psaes, mre, xprt and cf call no other app in this graph (CF only calls back into OSCAR).
- Overloaded words: [[system:adam-term]] (Navvy and Abigail say "ADAM" for AD), [[system:session-term]],
  [[system:form-term]], [[system:sourced-id-term]], [[system:external-login-key-term]], [[system:saveoff-term]],
  [[system:item-term]], [[system:admin-term]], [[system:iea-term]], [[system:client-term]] (customer code),
  [[system:record-type-term]], [[system:eligibility-term]], [[system:rollover-term]], [[system:lti-tool-term]].

## abigail → ad

**What it is for.** Abigail is an item-bank and review front end that keeps no content of its own. AD is the
system of record for items, forms, stimuli, media, resources, batteries, setting profiles, standards, workflow states,
stat sets and test cases ([[abigail:ad-data-api]]). Abigail adds what AD does not have: bank roles, permission scope,
per-state status permissions, metadata-field restrictions, edit-lock TTLs, reviews, administrations, test profiles,
extracts and AI bulk jobs ([[abigail:ad-content]]). Inside Abigail, AD is also called "ADAM" or "adam" (alias on
[[abigail:ad-data-api]], localStorage key `adam-ad-token`). This is not the separate ADAM app.

**Auth and protocol.** Credentials are set per Abigail account, in [[abigail:account-settings]] (`ad_host`, `ad_key`, `ad_secret`).
`ad_key` is an AD [[ad:credential]] `client_id`. [[abigail:ad-client]] mints a token with `POST /api/oauth/token`
(client_credentials, `scope=*`) ([[ad:auth-api]]), caches it for 10 min and re-mints once after a 401. Data calls are JSON REST
under `/api/data/banks/:adBankID/...`, with Mongo-style v2 list bodies and an envelope `{data,status,request_id,error,message}`.
The browser gets the same account token from `GET /api/ad/token` ([[abigail:ad-content-api]]) and uses it to load AD's
web components from the AD host ([[abigail:ad-web-components]]).

**Entity mirrors.** These are Go structs only, with no local tables. Each `maps to` column points at AD ids.
| Abigail | AD |
|---|---|
| [[abigail:bank]] (`ad_banks_id`) | [[ad:bank]] |
| [[abigail:item]] | [[ad:item]] (+ [[ad:trait]], [[ad:item-history]]) |
| [[abigail:form]] | [[ad:form]] (+ [[ad:section]], [[ad:stm]], [[ad:form-oscar-mapping]]) |
| [[abigail:stimulus]] | [[ad:shared-stimulus]] (+ [[ad:shared-stimulus-history]]) |
| [[abigail:media]] / [[abigail:resource]] | [[ad:media]] / [[ad:resource]] |
| [[abigail:battery]] | [[ad:battery-stm]] (+ [[ad:battery-unit]], [[ad:battery-objective]], [[ad:battery-outcome]]) |
| [[abigail:setting-profile]] | [[ad:form-setting-profile]] |
| [[abigail:standard]] | [[ad:standard]] |
| [[abigail:workflow-state]] | [[ad:bank-state]] |
| [[abigail:historical-stat-set]] | [[ad:historical-stat-set]], [[ad:historical-stat]], [[ad:stat-upload]] |
| [[abigail:scoring-stat-set]] | [[ad:scoring-stat-set]], [[ad:scoring-stat]] |
| [[abigail:item-test-case]] | [[ad:item-test-case]] |
| [[abigail:bank-secret]] | [[ad:bank-secret]] |
Local tables that store AD ids: [[abigail:administration-test-form]], [[abigail:extract-job]], [[abigail:extract-job-form-result]],
[[abigail:bulk-form-verification]] (forms). [[abigail:bank-gold-stat-set]], [[abigail:extract-template]] and [[abigail:test-profile]]
(historical stat sets). [[abigail:stimulus-template]] (stimulus). [[abigail:bank-role-status-permission]] (bank state).
[[abigail:bank-metadata-field-restriction]] ([[ad:bank-custom-field]]). [[abigail:bulk-generated-entity]],
[[abigail:bulk-verified-entity]] and [[abigail:bulk-generated-test-case]] (items and test cases).

**Interfaces used.** [[abigail:ad-content-api]] proxies `/api/ad/...` almost 1:1 to [[ad:banks-api]] (banks, bank states,
next identifier), [[ad:items-api]] (items, locks, test cases, versions, metadata/standards CSV, bulk ops, generic_score),
[[ad:stimuli-api]] (shared stims, resources), [[ad:forms-api]] (forms, setting profiles, test maps, form_scoring, print),
[[ad:media-api]], [[ad:standards-api]], [[ad:stats-api]] (stat sets, analytics/overview), [[ad:test-maps-api]] (battery STMs,
battery_scoring), [[ad:oscar-api]], [[ad:qti-packaging-api]], [[ad:xprt-api]] and [[ad:delivery-api]] (`POST /api/delivery/launch_token`
for preview, practice and reviewer player tokens). Other server callers: [[abigail:bank-admin]] (bank sync, copy config),
[[abigail:extracts]], [[abigail:review-service]], [[abigail:test-profile-service]], [[abigail:administration-service]],
[[abigail:job-queue]] (translations, metadata upload) and [[abigail:bulk-request-service]].

**Embedded frontends** (loaded from `${adHost}/public/`): `ad-authoring.js` ([[ad:ad-authoring]], contract [[ad:ad-authoring-ui]])
in [[abigail:items-ui]], [[abigail:stimuli-ui]], [[abigail:media-ui]]; `qti-item-components-v1.0.0.js` ([[ad:item-components]]);
`ad-test-player.js` ([[ad:adam-player]], contract [[ad:player-ui]]) in [[abigail:forms-ui]] and [[abigail:reviews-ui]].

**Inbound from AD.** [[ad:content-changed-callback]] hits `POST /agent/ai/index-entity` on [[abigail:agent-api]]
([[abigail:content-agent-proxy]]). AD attaches the `X-Internal-Route-Token` header through a [[ad:callback-credential]] on the subscription.
Abigail lowercases `entity_type`, resolves the bank by AD bank id and asks the content agent to reindex the entity.

**Gotchas.**
- Credentials belong to the account, not the bank. A shared bank is reached with whichever account's AD credentials the caller's JWT carries.
- `:bankID` on `/api/ad/banks/:bankID` accepts either the Abigail or the AD bank id. The client usually sends the AD id.
- Scope, status, lock and metadata-field checks run only in Abigail. A direct AD call with the browser token (web components) bypasses them.
- Locks are stored on the AD row (`locked_by_user`, `locked_timestamp`). Abigail computes the 1 h expiry, and read-then-lock is not atomic.
- `Form.setting_profile_id` has no omitempty, so a nil value detaches the profile in AD.
- `review_content.ad_content_id` is polymorphic (item, form, stimulus or media), so it has no `maps to`.
- Changing the AD host needs a full page reload because custom elements cannot be re-registered.
- Bank AI instructions and translation overrides exist on both sides as separate stores: [[abigail:bank-ai-instruction]] lives in the content agent and [[ad:bank-ai-instruction]] in AD. They are not mirrors.

## adam → ad

**Purpose.** ADAM (district assessment administration) uses AD as its content source, its test player and its item/test scorer. Three strands: **content** — AD banks/forms/items/traits/standards are pulled into ADAM's QTI tables and into Class Assignment copies; **delivery** — district sessions with `platform='AD'` and teacher Class Assignment sessions are registered on AD and played in AD's `<ad-test-player>`; **scoring** — ADAM reads AD responses/outcomes back and pushes teacher human scores and OSCAR scores to AD. Owner code: [[adam:ad-delivery]] (`src/ad` ADClient + `AdamADService` + `AdamADDeliveryService`), integration node [[adam:ad-platform]], settings [[adam:ad-integration-settings]].

**Auth / protocol.** Per-tenant OAuth2 client_credentials against [[ad:auth-api]] `POST /api/oauth/token` (`credentials.adam_ad_client_id/secret`, maps to [[ad:credential]]); bearer JWT with issuer `AD` ([[ad:token-issuer]]). Two hosts: `adam_ad_host` (data API + token) and `adam_ad_delivery_host` (falls back to the former). `ADClient` fetches a token per call; `AdamADService` caches it in Redis. ADAM also hands the tenant token to the browser (`GET /adam_ad/oauth/token`) and the legacy client / Vue 3 frontend call AD `/api/data/*` directly. Inbound: AD posts [[ad:host-callback]] JSON to ADAM's unauthenticated `POST /adam_ad/event` ([[adam:adam-ad-api]]); ADAM never sends `callback_url` at registration, so this relies on an AD [[ad:callback-subscription]] on ADAM's account. Since AD-5866 (2026-10) the legacy `accounts.callback_url` / `authoring_callback_url` columns are gone; ADAM's URL must exist as a subscription row, managed with `/api/admin/accounts/:id/callbacks` ([[ad:admin-api]]), not `PUT /api/admin/accounts`.

**Entity mirrors.**
| ADAM | AD | key |
|---|---|---|
| [[adam:qti-bank]] | [[ad:bank]] | `qti_banks.external_bank_id` |
| [[adam:qti-form]] (`form_type='ad'`) | [[ad:form]] | `qti_forms.ad_external_form_id` |
| [[adam:qti-item]] | [[ad:item]] | `qti_items.ad_external_item_id` |
| [[adam:trait]] (`qti_traits`) | [[ad:trait]] | `ad_external_id` |
| [[adam:standard]] | [[ad:standard]] | same lower-cased AB GUID ids |
| [[adam:session]] (`platform='AD'`) | [[ad:session]] | `sessions.external_login_key` |
| [[adam:session]] `session_sections` | [[ad:session-section]] | `section_code` = AD section identifier |
| [[adam:qti-item-result]] | [[ad:response]] / [[ad:response-trait-score]] | `external_response_id` |
| [[adam:program-outcome]] `session_outcomes` | [[ad:session-outcome]] | (session_id, objective_key) |
| [[adam:ad-test]] | [[ad:form]], [[ad:item]], [[ad:trait]], [[ad:standard]] | `form_id`, `external_item_id`, trait id, `external_standard_id` |
| [[adam:ad-test-session]] | [[ad:session]], [[ad:response]], [[ad:response-trait-score]] | `external_session_id`, item_results.id = AD response id |

**Interfaces used.** [[ad:banks-api]], [[ad:forms-api]], [[ad:items-api]], [[ad:stimuli-api]], [[ad:media-api]] (`fetch_response_from_s3` for recordings), [[ad:standards-api]] — content pull and the legacy authoring UIs ([[adam:legacy-qti-ad-authoring-ui]], [[adam:legacy-qti-item-editor-ui]], also [[ad:stats-api]], [[ad:xprt-api]], [[ad:ai-api]], [[ad:test-maps-api]]). [[ad:delivery-api]] — `registerSession`, `launch_token`, `sessions/{id}/state`, `section_change`, `sessions/current_info`, `GET sessions/{id}?cow=true`, `GET responses/{id}`. [[ad:human-scoring-api]] — `PUT /api/scoring/responses/{id}/score`, `POST /api/scoring/rescore`. Embedded frontends: [[ad:adam-player]] (`ad-test-player.js`, per [[ad:player-ui]]) and [[ad:item-components]] (`qti-item-components-v1.0.0.js`) loaded by [[adam:tester-ui-components]], [[adam:tester-api]] templates and legacy pages. ADAM does not embed [[ad:ad-authoring]] (its legacy editor is a fork; authoring moved to Abigail).

**Flows across the boundary.**
- Content: AD save → [[ad:content-changed-callback]] → `/adam_ad/event` → `AD_QTI_*_SYNC` SQS per client → `Sync*ByClient` GETs bank/form/item ([[adam:sync-ad-content]]).
- District delivery: [[adam:tester-api]] `adLaunch` → `registerSession` + state + section move → player → AD [[ad:score-session]] → [[ad:session-scored-callback]] → ADAM saveoff `doSyncAD` reads session/responses/outcomes ([[adam:deliver-ad-session]], [[adam:saveoff-service]]); [[adam:ad-section-sync-job]] polls `current_info` every 5 min. Since ADAM-2891/2894 `adLaunch` refuses sessions in `adLaunchClosedStates` (NOT_STARTED, RESEAT, SUBMITTED, SCORED, BREACHED, EXITED), so ADAM no longer registers unconfirmed, closed or breached sessions on AD. The proctor dashboard now maps AD's `current_item` (a UIN) to an item sequence from ADAM's own QTI form (`ADSessionInfo.CurrentItemSequence`, ADAM-2031); AD does not send a sequence.
- Scoring side effects: the session `admin_code` ADAM registers is passed by AD to MRE on every math response (`admin`, AD-5990; [[system:score-math-response-with-mre]]). AD outcomes now carry `lower_bound` / `upper_bound` (SEM / CI), which ADAM stores in `session_outcomes` / `program_outcomes` and COW ingests (COW-4552, see cow → adam).
- Constructed responses: either AD sends them to OSCAR from its own form mappings, or ADAM sends them (client setting `shouldSendOscarFromFinalize`); see ad ↔ oscar and adam ↔ oscar.
- Class Assignments: [[adam:ad-test]] built from AD form/items → [[adam:ad-test-session]] registered on AD → player progress posts to ADAM → `FetchSessionResponses` ([[adam:build-and-assign-class-assignment]]).
- Human scoring: [[adam:results-service]] / class scoring push trait scores to AD, AD re-scores ([[ad:human-score-response]]), ADAM re-pulls ([[adam:hand-score-responses]]).

**Gotchas.**
- ELK: for AD sessions `external_login_key` is the AD session id; Class Assignment sessions use `external_session_id` instead — two id columns for the same AD concept.
- Content-sync and section-sync call delivery endpoints on `adam_ad_host`; launch uses `adam_ad_delivery_host` — they diverge once a tenant splits hosts.
- Webhook-driven sync only reaches clients that already have the AD bank (`GetClientIDsByExternalBankID`); first sync is manual (`POST /adam_ad/sync/...`). STIMULUS/MEDIA callbacks are ignored.
- AD session state is copied verbatim into `sessions.state`; AD outcomes overwrite ADAM program scoring (default program scoring disabled).
- `ad_test_items.external_item_id` is globally unique, so tenants sharing an AD item share one row.
- The tenant-wide AD token sits in browser localStorage and print URLs (`?token=`).
- ADAM standards and AD standards share AB GUID ids, so `standard.id` values are interchangeable.
- Do not recreate ADAM's callback on AD with the old account field: `accounts.callback_url` no longer exists (AD-5866). Check the account's callback subscriptions instead.

## adam → lp

**What it is for.** LaunchPad ([[adam:launchpad]]) is the roster/SSO hub for districts whose ADAM org has `sync_type='launchpad'`. ADAM is a [[lp:subscribed-app]] of the LP [[lp:client]]: it pulls the roster over LP's OneRoster 1.2 API, receives per-row change webhooks, and logs users in through LP's OIDC provider. Flow: [[adam:sync-launchpad-roster]]; LP side: [[lp:app-pulls-roster-via-oneroster-api]], [[lp:notify-apps-of-roster-change]], [[lp:sso-into-app]].

**Auth / protocol.**
- Roster pull: OAuth2 client_credentials at `POST /api/external/oauth2/token`. `client_id` = LP [[lp:app-config]] id, stored in ADAM [[adam:credential]] `launchpad_client_username`, plus `launchpad_client_secret` and `launchpad_api_host`. The LP tenant in the path comes from `orgs.launchpad_client_id` (= [[lp:client]] id). LP grants access only if the [[lp:app-subscription]] has `data_sharing=true`, and its `scope` (orgIds/courseIds/grades) filters every read.
- Webhooks in: LP gets a bearer token from ADAM's own `POST /ims/oneroster/rostering/v1p2/oauth/token`, using ADAM `oauth_credentials` stored in LP `app_subscriptions.notification_info`, then POSTs to `.../sync` on [[adam:oneroster-data-api]].
- SSO: OIDC authorization code. [[adam:launchpad-auth-api]] (`/lp/login`, `/lp/callback`) acts as the RP of [[lp:sso-oidc-provider]]. Config is in the global settings `LAUNCHPAD_CLIENT_ID/SECRET/AUTH_DOMAIN`.

**Entity mirrors** (ADAM `sourced_id` = LP **LPI**, because LP's API emits `sourcedId = lpi`):
| ADAM | LP | id mapping |
|---|---|---|
| [[adam:org]] | [[lp:org]] | `orgs.sourced_id` → `lpi`; `orgs.launchpad_client_id` → [[lp:client]] `id` |
| [[adam:academic-session]] | [[lp:academic-session]] | `sourced_id` → `lpi` |
| [[adam:course]] | [[lp:course]] | `sourced_id` → `lpi` |
| [[adam:class]] | [[lp:class]] | `classes.sourced_id` → `lpi` |
| [[adam:class]] (`user_classes`) | [[lp:enrollment]] | `user_classes.sourced_id` → `lpi`; `school_sourced_id` → [[lp:org]] `lpi` |
| [[adam:user]] | [[lp:user]] | `users.sourced_id` → `lpi` (also the student username) |
| [[adam:user]] (`user_orgs`) | [[lp:role]] | `user_orgs.role_sourced_id` → `lpi` |
| [[adam:demographics]] | [[lp:demographic]] | `user_sourced_id` → [[lp:user]] `lpi` (an LP demographic's sourcedId is its user's LPI) |

**Interfaces used.**
- [[lp:oneroster-external-api]]: `GET /api/external/{clientId}/ims/oneroster/rostering/v1p2/{orgs,academicSessions,courses,classes,users,enrollments,demographics}` with `filter=dateLastModified>…`, the `/stats` counts, and single-object GETs for real-time sync. The callers are [[adam:roster-sync]] (`LaunchpadSyncService`) and the [[adam:launchpad-sync-api]] / cron triggers.
- [[lp:roster-change-notification]] → [[adam:oneroster-data-api]] `POST .../sync` → `REAL_TIME_LAUNCHPAD_SYNC` ([[adam:launchpad-sync-message]]) → `ProcessRealTimeSync` re-fetches the referenced object.
- [[lp:sso-oidc-provider]]: `/auth/oidc/authorize`, `/auth/oidc/oauth/token`, `/auth/oidc/userinfo`. `sub` (the LPI) is matched to `users.sourced_id`.

**Gotchas.**
- **The gradebook push is broken against current LP.** `LaunchpadCategoriesService` (`ONE_ROSTER_CATEGORIES`) POSTs `.../categories`, `.../lineitems` and `.../results`. LP's external API is GET-only and dropped its gradebook tables in 2025-04 ([[lp:measures]], dead). Every push therefore fails, and ADAM's consumer never retries it.
- The LP notification `ref.sourcedId` is the LPI, and `clientId` is the **subscribing** client, which can be an ancestor of the row's client. ADAM resolves the org by `orgs.launchpad_client_id = clientId`, so a parent-level subscription only works if that org carries the parent's id.
- LP replays after a crash produce new `messageId`s. ADAM does not dedupe; it re-fetches the object, which is idempotent.
- LP scopes notification tokens with credentials ADAM issued. ADAM's OneRoster API does not enforce scopes, so the same token can also read `/sessions` and ISR data.
- ADAM's delta watermark is the start time of its own last run, compared with LP `updated_at` (`dateLastModified`), not with the source system's `originalDateLastModified`.
- A user's OneRoster role is an [[lp:role]] row. ADAM keeps it only as `user_orgs.role_sourced_id`. [[adam:role]] is an unrelated ADAM permission set.

## adam → cow

**What it is for.** COW ([[adam:cow]], [[adam:cow-term]]) is Pearson's reporting/extract platform. ADAM relies on it for two things:
1. Reading the per-session documents (scanned paper ISRs and similar) that COW's Doc Depot ([[cow:doc-depot]]) keys by external login key. These are shown in the ISR modal through [[adam:cow-api]].
2. Importing report-asset files that COW stages into ADAM's S3 bucket together with a manifest CSV.

The reverse direction (COW reading ADAM's Postgres replica) is owned by COW: see [[cow:adam]] and [[cow:etl-adam-db-connection]]. ADAM has no node for the COW-dedicated replica beyond [[adam:postgres]].

**Auth / protocol.**
- `POST {COW_AUTH_URL}/idToken` with `{client_id, client_secret, grant_type}` returns `{idToken}` ([[cow:platform-tenant-management-api]]).
- `GET {COW_URL}/reporting/v1/elk/{key}` on the tenant [[cow:reporting-api]] sends the token raw in `Authorization`, without a `Bearer` prefix. The tenant Lambda authorizer resolves the tenant from it.
- Config lives in [[adam:cow-settings]] (global settings `COW_AUTH_URL`, `COW_URL`, `COW_CLIENT_ID`, `COW_CLIENT_SECRET`, `COW_CLIENT_MAP`).

**Entity mirrors / ids.**
| ADAM | COW | id mapping |
|---|---|---|
| [[adam:report-asset]] (`report_assets`) | [[cow:adam-manifest-row]] | same 13 columns; `identifier` → `identifier`, `scope_identifier` → `scope_identifier`; manifest `hard_id` = ADAM org/class id (resolved into `scope_id`) |
| [[adam:session]] `id` / `external_login_key` ([[adam:external-login-key]]) | [[cow:document]] `externalLoginKey` | ADAM sends its **session id** as the ELK |

**Interfaces / flows across the boundary.**
- [[adam:cow-service]] `GetDocumentsBySessionIDs` → [[cow:reporting-api]] `GET /reporting/v1/elk/{externalLoginKey}`, returning a `CowDocument` with presigned `downloadURL`/`uploadURL`. This uses the DynamoDB GSI `tenant_externalLoginKey` and returns the first match only.
- COW delivery ([[cow:xi-deliver-completed-order]] "Stage for ADAM" → [[cow:reporting-delivery-lambdas]] `s3CopyLambda` + `createAdamManifestLambda`) copies the files and `<name>_manifest.csv` into ADAM's internal bucket ([[adam:s3]]) under `imported/<program>/<admin>/` (`buildADAMOutputPath`). Something outside both repos then sends SQS `COW_REPORT_ASSETS_IMPORT {location}` ([[adam:admin-data-job-message]]). [[adam:cow-service]] maps `<program>` (the folder) to an ADAM client via `COW_CLIENT_MAP` and re-queues the location as `REPORT_ASSETS_IMPORT`, which creates [[adam:job-history]] rows.
- [[cow:xi-adam-manifest]] can produce the same CSV on demand, but it stays inside COW as a PWS document.

**Gotchas.**
- ADAM queries `/elk/` with its session UUIDs. This only works where the session's ELK equals its id: new AD sessions do, but TestNav sessions can have a separate StudentTestUUID ([[cow:etl-external-login-key]]).
- The folder segment is the lower-cased first word of the extract name (`program`, default `cow`), or `target_path` for "ADAM PDF". `COW_CLIENT_MAP` must contain exactly that string, or the import fails with "No client mapping".
- ADAM fetches a new COW token on every call, with no caching. Per-session fetch failures are dropped silently, so a partial result still returns 200.
- COW's ltr/ltr2 destination-role bug in `deliver_adam_to_s3` can stop files from ever reaching ADAM's bucket ([[cow:reporting-delivery-lambdas]]).

## adam → abigail

**What it is for.** Abigail is the external authoring app for AD content (banks, items, forms). It replaced ADAM's in-app AD authoring screens ([[adam:legacy-qti-ad-authoring-ui]], ADAM-706). The Vue 3 [[adam:frontend-app]] sidebar shows one external "ABIGAIL" link, which opens Abigail's [[abigail:web-client]] in a new tab.

**Auth / protocol.** None between the two apps. The link is a plain URL taken from the ADAM global setting `ABIGAIL_URL` (served by `GET /v2/app_urls`). The user signs in to Abigail separately ([[abigail:sign-in]]: email/password or Microsoft SSO). ADAM passes no token, user id or context.

**Visibility.** The link appears only when all three of these hold:
- AD is enabled for the user (`/adam_ad/enabled_for_user`, [[adam:adam-ad]]);
- `ABIGAIL_URL` is set (otherwise the link is hidden, not shown dead);
- the user's ADAM [[adam:role]] has `ad_banks >= 1`.

**Entity mirrors.** None. ADAM and Abigail meet only through AD. Both hold AD content: ADAM syncs it for scheduling and delivery ([[adam:ad-platform]]), and Abigail calls AD's Data API ([[abigail:ad-data-api]]). Those links belong to the adam→ad and abigail→ad pairs.

**Interfaces used.** [[abigail:web-client]] (SPA root). No API calls.

**Gotchas.**
- Bookmarked ADAM `/authoring/*` routes now render NotFound. Authors must use Abigail.
- ADAM's `ad_banks` permission column only decides whether the link is visible. Abigail enforces its own bank roles.
- Learnosity and QTI authoring remain inside ADAM ([[adam:item-and-form-authoring]]). Only AD authoring moved.

## arc → ad

**What it is for.** ARC is an LTI classroom app; it authors nothing. Every test a student takes in ARC is an AD form,
delivered by AD's embeddable player and scored by AD. ARC registers the session, embeds the player, receives AD's
"session changed" callback, then pulls the scored session back into its own tables and passes the grade to the LMS.

**Auth / protocol.** One AD tenant per [[arc:connection]]: `settings.host` (AD base URL), `client_id`, `client_secret`
are an [[ad:credential]] of an [[ad:account]]. [[arc:ad-client]] mints a token with `POST /api/oauth/token`
(client_credentials, `scope=*`) on [[ad:auth-api]] and caches it per connection for ≤5 min. All other calls are
`Bearer` JSON against AD's `{status, data, …}` envelope. The player gets its own launch JWT from `registerSession`.

### Entity mirrors
| ARC | AD | id link |
|---|---|---|
| [[arc:attempt]] | [[ad:session]] | `attempts.id` **is** the AD session id (ARC passes it as `session_id`); `form_id` → `ad:form.id` |
| [[arc:attempt-response]] | [[ad:response]] | `item_id` → `ad:item.id`, `item_reference` → `ad:item.identifier`, `client_id` → `ad:account.id` |
| [[arc:attempt-outcome]] | [[ad:session-outcome]] | `attempt_id` → `ad:session.id`; `objective_key` = STM [[ad:objective]] key |
| [[arc:resource]] | [[ad:form]] | `forms[]` = `{bank, id, accom, primary_outcome}` (AD bank/form uuids); `metadata.bank_id` → `ad:bank.id` |
| [[arc:connection]] | [[ad:credential]] | `settings.client_id` |
| [[arc:legacy-session-outcome]] (dead) | [[ad:session-outcome]] | same shape; table unused |

### Interfaces used
| ARC caller | AD interface | routes |
|---|---|---|
| [[arc:delivery]] | [[ad:delivery-api]] | `POST /api/delivery/registerSession` (session_id, callback_url, pnp, cat_settings, form_settings), `PUT /sessions/{id}/state` |
| [[arc:delivery]], [[arc:admin]] | [[ad:forms-api]] | `GET /api/data/banks/{bank}/forms_light`, `GET …/forms/{form}` (CAT sections, STM for the primary-outcome picker) |
| [[arc:admin]] | [[ad:banks-api]] | `POST /api/data/banks/` |
| [[arc:async-workers]] | [[ad:delivery-api]] | `GET /api/delivery/sessions/{id}` (state, scores, responses, session_outcomes) |
| [[arc:proctoring]] | [[ad:delivery-api]] | `POST /sessions/current_info` (≤25 ids), `PUT /sessions/{id}/state` (pause/resume/submit) |
| [[arc:app-backend]] | [[ad:human-scoring-api]] | `POST /api/scoring/rescore {session_ids}` (admin rescore), `PUT …/state` on attempt reset |
| [[arc:learner-ui]] | [[ad:adam-player]] / [[ad:player-ui]] / [[ad:item-components]] | loads `{host}/public/ad-test-player.js` and `qti-item-components-v1.0.0.js`, calls `init(launch_token)`, listens to `updateSessionState` / `updateTestState` |
| [[arc:ad-event-webhook]] | [[ad:host-callback]] | AD POSTs `{entity_type, entity_id, session_id, bank_id}` to `$APP_URL/api/ad/event` |

### Flows across the boundary
- **Launch:** [[arc:take-assessment]] → attempt created → `registerSession` (with CAT blocklist + starting theta from prior
  [[arc:attempt-response]]/[[arc:attempt-outcome]]) → launch token → player in [[arc:learner-ui]] talks to AD directly.
- **Score:** student submits → AD [[ad:score-session]] → [[ad:host-callback]] → [[arc:ad-event-webhook]] →
  [[arc:saveoff-message]] → [[arc:async-workers]] `GET /sessions/{id}` → attempt/outcomes/responses rewritten → passback to LMS
  ([[arc:saveoff-and-passback]]).
- **Rescore:** [[arc:admin-rescore-attempts]] → `POST /api/scoring/rescore` → AD re-runs save-off → same callback path.
- **Proctor:** [[arc:proctoring]] polls `current_info` and pushes state changes.

### Gotchas
- ARC never stores an AD session id separately — losing the "attempt id = session id" rule breaks every AD call.
- Per-session `callback_url` overrides the AD account's [[ad:callback-subscription]] rows, so ARC needs no subscription.
- [[arc:ad-event-webhook]] is unauthenticated and always passes back; AD sends the callback unsigned unless a
  [[ad:callback-credential]] exists for the URL.
- The player is served from the connection's AD host, so ARC page and player can come from different AD envs per connection.
- `forms_light` is mandatory for listing (full `/forms` ~13 MB each); `current_info` returns 500 above 25 ids.
- ARC outcomes are deleted and re-inserted on each saveoff; AD is the source of truth for scores.
- "Resource" means an assignable test (wrapping AD forms) in ARC but section-intro content in AD ([[arc:resource-term]] vs [[ad:resource-term]]).

## navvy → ad

**What it is for.** Navvy is a host application for AD. Navvy owns rostering, assignment, check selection and reporting. AD owns item content, the test player, the delivery session and scoring. Navvy calls AD "ADAM AD" ([[navvy:adam-ad]]). Do not confuse this with the separate ADAM app, which has its own AD integration at [[adam:ad-platform]]. Navvy keeps a read-mostly mirror of AD items and stores AD ids on its own rows. Every test is delivered and scored in AD, and Navvy pulls the results back.

**Auth / protocol.** REST/JSON. OAuth2 client credentials (`ADAM_AD_CLIENT_ID`/`SECRET`, [[navvy:adam-ad-config]]) get a token from `POST /api/oauth/token` ([[ad:auth-api]]). Navvy uses two base URLs. The backend host serves `/api/data` and the token. The delivery host serves `/api/delivery` and `/api/scoring`. The token is cached per facade instance. AD calls back unauthenticated to `POST /api/callbacks/ad/score_complete` ([[navvy:ad-callback-api]]).

**Entity mirror table**
| Navvy | AD | id field(s) |
|---|---|---|
| [[navvy:item]] | [[ad:item]] | `adam_item_id` → `ad:item.id`, `adam_item_reference` → `ad:item.name`, `version` → `ad:item.version` |
| [[navvy:form]] (dynamic item set) | [[ad:form]] | `ad_form_id` → `ad:form.id` |
| [[navvy:assessment-form]] (fixed form) | [[ad:form]] | `ad_form_id`, `ad_form_identifier` → `ad:form.identifier` |
| [[navvy:testing-session]] | [[ad:session]] | `adam_session_uuid` → `ad:session.id`, `ad_session_state` → `state`, `ad_scoring_state` → `score_status` |
| [[navvy:testing-session-response]] | [[ad:response]] | none (rebuilt per sync, item looked up by reference) |
| [[navvy:ad-score-complete]] | [[ad:session-scored-callback]] | payload `session_id` = `ad:session.id` |
| [[navvy:assessment]], [[navvy:subject]] (not mirrors) | [[ad:bank]], [[ad:bank-state]] | `ad_bank_id`/`adam_bank_id` → `ad:bank.id`, `adam_form_state_id` → `ad:bank-state.id` |

**Interfaces used**
- [[ad:banks-api]] `POST /api/data/banks`. [[ad:items-api]] `POST /api/data/banks/:bank/items` and `GET .../items/:item`. [[ad:stimuli-api]] `POST .../shared_stims`. These calls run in [[navvy:adam-item-sync]] and [[navvy:assessments-service]].
- [[ad:forms-api]]: fixed form lookup (`/api/data/banks/:bank/forms[/:form]`) and `PUT /api/data/banks/:bank/form_by_item_ids`. Navvy builds a dynamic form for every launch, after it has chosen the items itself.
- [[ad:delivery-api]]: `registerSession` with a display name and `callback_url`, `launch_token`, `sessions[/:id]`, `sessions/:id/state` (force submit to SUBMITTED), `responses/:session` and `delete_file`. The callers are [[navvy:testing-sessions-service]], [[navvy:assessments-service]] and [[navvy:external-integrations]] (outcomes for MetaMetrics).
- [[ad:human-scoring-api]] `POST /api/scoring/rescore`. [[ad:media-api]] `POST /api/data/fetch_response_from_s3` (ORF miscue audio).
- Text-to-speech ([[navvy:assessment-tts]], Progress Checks only): Navvy reads the form's `form_settings` TTS flags from [[ad:forms-api]] (authored in Abigail) and sends `form_settings` + a spoken `pnp` block with `form_by_item_ids` and `registerSession` ([[system:ad-register-session]], [[system:pnp-term]]). It never sends `read_aloud`, which AD treats as an unscored practice launch.
- Player routes with the **student's player token** (not the client-credentials token): `POST /api/delivery/player/response` and `GET /api/delivery/player/form/:id` for writing-practice draft prefill; the delivery `client_id` in that payload comes from the AD session's `account_id`.
- Frontend: [[navvy:student-testing-ui]] embeds [[ad:adam-player]] (`ad-test-player.js` from `/public/`) and drives it through the [[ad:player-ui]] contract with the launch token.

**What crosses the boundary**
- Items and stimuli flow AD → Navvy twice a day (`0 8,20 * * *`), plus lazily when a response references an unknown item.
- At launch ([[navvy:take-check]]), Navvy sends the selected items to AD as a form. Navvy then registers the session with its callback URL, mints a token, and the browser loads the AD player.
- When scoring finishes, AD save-off ([[ad:score-session]]) posts [[ad:session-scored-callback]] through [[ad:host-callback]]. Navvy then pulls the session, outcomes (RAW_SCORE, SCALE_SCORE, THETA, PERCENT_CORRECT, MARKOFF_ADJUSTED, NAVVY_PENALTY_CORRECTED…) and responses ([[navvy:score-testing-session]]).
- Rescore and force-submit go Navvy → AD ([[navvy:force-submit-attempts]], [[navvy:resync-testing-sessions]]).

**Gotchas**
- A Navvy session counts as complete only when AD state is `SUBMITTED` **and** score status is `SCORE_COMPLETE`.
- The callback is unauthenticated, at-most-once and unreliable for force-submitted sessions (NAVVY-5877). Navvy compensates by polling.
- `ad:item.id` identifies one item **version**. Navvy stores that id plus `version`, and joins responses by `ad:item.name` (the reference), not by id.
- Standards are not mirrored from AD. Navvy imports standards directly from Academic Benchmarks, and `standards.id` is the AB GUID. AD standard ids are also AB GUIDs, so the AD standard ids in `items.properties.standards` resolve to Navvy standards rows because both sides use the same key.
- The facade has no HTTP timeout. A 500 response returns no error and fails later, at JSON decode.
- `GET /api/assessments/ad_auth_token` returns Navvy's AD Data API token to any signed-in user.
- AD stores OSCAR's `scoredResponseText` on responses "for Navvy" (AD-6322), but Navvy does not read it ([[system:ai-score-explanation-to-host]]).

## navvy → lp

**What it is for.** LaunchPad is the source of truth for Navvy's districts, schools, classes, teachers and students. It is also the identity provider for "Sign in with Launchpad". Navvy only reads from LaunchPad: it pulls the roster over OneRoster 1.2 ([[lp:app-pulls-roster-via-oneroster-api]]) and signs users in over OIDC ([[lp:sso-into-app]]). Navvy is a [[lp:subscribed-app]] registered as an [[lp:app-config]].

**Auth / protocol**
- Roster: OAuth2 client credentials (`LAUNCHPAD_CLIENT_ID`/`SECRET` = the LP `app_configs.id` and secret, [[navvy:launchpad-config]]). The token comes from `{oneroster_base_url}/oauth2/token`. Navvy then calls `GET {oneroster_base_url}/{client.oneroster_client_id}/ims/oneroster/rostering/v1p2/{entity}?limit=1000&filter=dateLastModified>…` and follows `next` ([[lp:oneroster-external-api]]). Access also requires an [[lp:app-subscription]] with `data_sharing` turned on.
- SSO: OIDC authorization code against `LAUNCHPAD_AUTH_DOMAIN` ([[lp:sso-oidc-provider]] `/auth/oidc/*`), with scopes `openid profile email`. [[navvy:auth-api]] exposes `/api/launchpad_login` and `/api/launchpad_callback`.

**Entity mirror table** (Navvy `sourced_id` holds the LP [[lp:lpi]], `lpi:env:client_code:type:sourcedId`)
| Navvy | LP | id field |
|---|---|---|
| [[navvy:client]] | [[lp:client]] | `oneroster_client_id` → `lp:client.id` |
| [[navvy:org]] | [[lp:org]] | `sourced_id`, `parent_sourced_id` → `lp:org.lpi` |
| [[navvy:academic-session]] | [[lp:academic-session]] | `sourced_id` → `lp:academic-session.lpi` |
| [[navvy:course]] | [[lp:course]] | `sourced_id` → `lp:course.lpi` |
| [[navvy:class]] | [[lp:class]] | `sourced_id` → `lp:class.lpi` |
| [[navvy:user]] | [[lp:user]] | `sourced_id` → `lp:user.lpi` (also the OIDC `sub`) |
| [[navvy:enrollment]] | [[lp:enrollment]] | `sourced_id` → `lp:enrollment.lpi` |
| [[navvy:user-demographic]] | [[lp:demographic]] | none (keyed by user); metadata `ell`, `section504`, `iep`, `gt` |

OneRoster `roles` are not stored as separate rows. They become [[navvy:user-org]] rows and the Navvy role on [[navvy:user]].

**Interfaces used.** [[navvy:roster-sync-service]] (driven by [[navvy:sync-launchpad]] nightly at `0 4 * * *` UTC, or on demand from the roster sync UI) calls [[lp:oneroster-external-api]]. [[navvy:auth-service]] and [[navvy:auth-api]] call [[lp:sso-oidc-provider]]. The integration node is [[navvy:launchpad]].

**What crosses the boundary.** Roster deltas flow LP → Navvy. The nightly sync looks back 48 h, and a full sync runs on Saturdays (`LAUNCHPAD_FULL_SYNC_WEEKDAYS`). The OIDC identity (`sub` = user LPI, plus email) also flows LP → Navvy. Nothing flows Navvy → LP.

**Gotchas**
- Navvy does not consume LP's roster change webhooks ([[lp:notify-apps-of-roster-change]]). It polls instead, so changes reach Navvy up to a day late.
- SSO only works for users that roster sync has already created. The callback matches `sourced_id = sub` AND email, so a user whose LP email changed but who has not been re-synced gets a 401.
- Navvy calls the stored value `sourcedId`, but it is the full LPI. LP's own `sourced_id` column holds only the last segment ([[lp:sourced-id]]). Navvy lookups accept either form.
- The LP OneRoster token lasts 30 min, but Navvy fetches a new token for every page because of a pass-by-value bug.
- LP data is per school year. Navvy sends no `school_year`, so it always reads the client's current year.

## navvy → arc

**What it is for.** Navvy has no lockdown browser of its own. When a client enables `lock_student_browser` ([[navvy:client]] column and a [[navvy:client-org-configuration]] row), the Navvy test player ([[navvy:student-testing-ui]], `AssessmentPlayer.vue`) borrows ARC's Chrome lockdown extension ([[arc:lockdown-extension]]).

**Protocol.** In-page JS only, with no server-to-server call. The extension's content script injects `window.ArcExtension` ([[arc:lockdown-extension-api]]) on hosts in its manifest allow-list, which includes `https://*.navvyeducation.com/*`. Navvy constructs `new ArcExtension("lejnbofknclafimlnbgnaaamehkchifm")`, the same Web Store id as ARC's [[arc:extension-id]] default, but hard-coded in Navvy. Each method is a `chrome.runtime.sendMessage` to the extension, with no auth.

**Methods Navvy uses**
| Navvy call | ARC API | effect |
|---|---|---|
| `ldb.lock()` on player load | `lock` | locks the current tab's window |
| `ldb.onbreach(cb)` | [[arc:lockdown-breach]] | Navvy sets `extensionState.breached` and shows a blocking dialog |
| `ldb.unlock()` on unmount / submit | `unlock` | releases the window |

Navvy does **not** use `launch()`, `getStatus()` or `getDeviceInfo()`, unlike ARC's own flow ([[arc:secure-test-delivery]]). It locks the tab it is already running in.

**Entity mirrors.** None. No ARC data is stored in Navvy, and breaches are not recorded server-side. ARC itself records breaches on `attempt.breached` ([[arc:lockdown-breach-handling]]).

**Gotchas**
- Lockdown is advisory. If the extension is missing, Navvy shows an "install lockdown extension" modal. Closing the modal writes `<userId>-isLockDownBrowserRequired = "false"` to localStorage, and every later launch skips locking.
- The extension id is hard-coded in Navvy. If ARC changes its `EXTENSION_ID` or republishes the extension under a new id, Navvy breaks silently.
- The extension's `externally_connectable` is `<all_urls>`, so any page can call it. Only the content-script injection of `window.ArcExtension` depends on the host allow-list.
- Navvy's dependency on ARC is the browser extension only, not the ARC server. Navvy tests still run in the AD player ([[ad:adam-player]]).

## cow → adam

**What it's for.** ADAM is COW's main source system. Almost every fact in [[cow:redshift-warehouse]] (one Data Vault database per admin_code) is copied out of ADAM. COW only reads ADAM's data and never writes to its database. Its one write path is file delivery: COW drops extract and report files plus a manifest into ADAM's internal S3 bucket, and ADAM imports them as report assets. Integration node: [[cow:adam]]. On the ADAM side the matching node is [[adam:cow]], with [[adam:cow-service]] / [[adam:cow-api]].

**Auth / protocol.**
- **Main path: JDBC to ADAM Postgres** ([[adam:postgres]]). COW connects to the COW-dedicated reader endpoints `adam-*-cow-ro`. Credentials come from Secrets Manager, chosen by `ADAMDBEnv` ([[cow:etl-adam-db-connection]]). This path is used by [[cow:glue-ingest-jobs]], [[cow:etl-ecs-worker]], [[cow:glue-adam-extract-jobs]], the warm-up lambda in [[cow:etl-ingest-orchestrator]] and the dead [[cow:etl-validation-framework]].
- **Legacy: ADAM OneRoster 1.2 REST API** ([[adam:oneroster-data-api]]). The flow is `POST .../oauth/token` with client_credentials, then a paged `GET .../orgs|students|teachers|demographics|courses|classes|enrollments|sessions|battery_scores?modifiedAfter=`. It is called by [[cow:oneroster-ingest-app]] and lands as [[cow:data-oneroster-payload]] JSON. The Glue job that loaded it ([[cow:etl-legacy-adam-json-ingest]]) is dead.
- **Delivery back to ADAM: cross-account S3** ([[adam:s3]]). [[cow:reporting-delivery-lambdas]] runs `stageAdam` → `createAdamManifest` → `deliverAdamToS3` into `adam-*-internal`, under `imported/<program>/<admin>/<order>/…` plus `<name>_manifest.csv`. The Extract Interface can rebuild the same CSV on demand ([[cow:xi-adam-manifest]], flow [[cow:xi-adam-manifest-download]]).

**Entity mirror table** (Data Vault hub business key ↔ ADAM column)
| COW | ADAM | key |
|---|---|---|
| [[cow:test-attempt]] | [[adam:session]] (+ [[adam:accommodation]]) | `testattempt_id` = sessions.id; `external_login_key`; `form_identifier`/`test_number` → [[adam:qti-form]]; `admin_code` → [[adam:admin]] |
| [[cow:person]] | [[adam:user]] (+ [[adam:demographics]]) | `person_id` = users.sourced_id; `adam_student_uuid` / `sat_person.adam_id` = users.id |
| [[cow:organization]] | [[adam:org]] | `organization_id` = orgs.sourced_id; `identifier` |
| [[cow:class]] | [[adam:class]] | `class_id` = classes.sourced_id; `class_code` |
| [[cow:proctor-group]] | [[adam:proctor-group]] | `proctor_group_id` = proctor_groups.id |
| [[cow:battery-attempt]] | [[adam:student-battery]] | `battery_uuid` = student_batteries.id; `battery_code` → [[adam:battery]] |
| [[cow:item]] | [[adam:qti-item]] | `item_id` = qti_item_results.qti_item_reference (item identifier) |
| [[cow:item-outcome]] | [[adam:trait]] | `item_outcome_id` = qti_traits.external_id |
| [[cow:external-score]] | [[adam:qti-item-result]] (qti_external_scores) | `ref_id`; `external_id` = trait external_id |
| [[cow:tools-analytics]] | [[adam:qti-item-result]] | `tools_analytics_id` = qti_item_results.id |
| [[cow:result-category]] | [[adam:program-outcome]] | `resultcategory_category` = objective_key (session_outcomes / program_outcomes) |
| [[cow:admin-client]], [[cow:etl-datasource-id]] | [[adam:client]] | `datasource_id` = clients.id |
| [[cow:etl-external-login-key]] | [[adam:external-login-key]] | ELK = sessions.external_login_key |
| [[cow:adam-manifest-row]] | [[adam:report-asset]] | `hard_id` → scope_id, `identifier`, `scope_identifier`, `bucket`, `path` |

**What flows across.** From ADAM into COW: rosters (orgs, users, demographics, classes, enrollments), sessions with accommodations, sections and outcomes, item results, trait scores, external (hand/AI) scores, battery scores and accountability codes (`clients.acc_codes` → `ref_accountability_codes`). From COW back into ADAM: extract and report files plus the manifest CSV.
Score bounds (COW-4552): AD session outcomes → ADAM `session_outcomes.lower_bound` / `upper_bound` (`level` too) → COW `sat_link_testattempt_result` and four outcome views ([[cow:glue-ingest-jobs]], [[cow:mv-sdf-extract]]); `level` is deliberately not on the views and no extract reads the bounds yet ([[system:outcome-term]]).

**Gotchas.**
- Hash keys are `md5(<adam id>||datasource_id)` ([[cow:wh-data-vault-conventions]]).
  - Since V57, [[cow:person]] hashes the ADAM `users.id`, but `person_id` still holds the OneRoster sourcedId.
  - `hub_class.class_id` is unique without datasource_id.
- Training admins, temporary students and deleted sessions are filtered out in SQL. A COW count will not match a raw ADAM count.
- **Manifest import seam.** ADAM's `COW_REPORT_ASSETS_IMPORT` SQS message ([[adam:admin-data-job-message]]) is sent by an S3-triggered producer that lives in neither repo.
  - ADAM maps the `<folder>` (the COW `program` path segment) to a client via the `COW_CLIENT_MAP` global setting.
  - `hard_id` is resolved as a class or org id ([[adam:internal-operations]]).
- **The reverse direction is not COW's dependency.** ADAM reads COW documents by ELK (`GET /reporting/v1/elk/{key}` on [[cow:reporting-api]], token from [[cow:platform-tenant-management-api]]). It passes its own session ids, which match [[cow:document]] `externalLoginKey` (mapped to adam:session.external_login_key).
- A new `session_outcomes` column must be added in all five COW loaders ([[cow:glue-ingest-jobs]]); the bounds were the last one.
- There are no direct COW→AD links. For AD-delivered sessions the ELK is the AD session id ([[ad:session]]), but COW only receives it through ADAM.
- Secret ARNs and hosts are hardcoded in several scripts. Ingest and extract jobs use different env keys (`act` vs `ltr-act`).

## cow → lp

**What it's for.** Only the forensics / test-security dashboard ([[cow:test-security-dashboard]]) talks to LaunchPad. It uses LaunchPad for two things:
- **Sign-in:** OIDC login.
- **Data scope:** the user's OneRoster roles and orgs decide whether they see all, district or school data.

No COW warehouse data comes from LaunchPad; rosters reach COW through ADAM. Integration node: [[cow:forensics-launchpad]]. The logic is in [[cow:forensics-auth]], and the routes are in [[cow:forensics-api]].

**Auth / protocol.**
- **OIDC.** COW is an OIDC client of [[lp:sso-oidc-provider]].
  - The issuer is `https://launchpad-*/auth/oidc`, with discovery at `/auth/oidc/.well-known/openid-configuration`.
  - The flow is authorization code with PKCE S256, scopes `openid profile email`.
  - The client id and secret are an LP [[lp:app-config]] (`client_id` = app_configs.id), stored in SSM ([[cow:forensics-config]]).
  - Claims used: `sub` = the user's [[lp:lpi]], `clientId` = the LP [[lp:client]] (tenant) id, plus email and name.
- **OneRoster 1.2 rostering.** COW calls [[lp:oneroster-external-api]] (implemented by [[lp:external-api]]) with the same app credentials.
  - Token: `POST /api/external/oauth2/token` (client_credentials, `roster.readonly` + `roster-demographics.readonly`).
  - Then `GET /api/external/{clientId}/ims/oneroster/rostering/v1p2/users/{sub}` and `/orgs/{sourcedId}`.

**Entities read (no local mirror).** [[lp:user]] (with roles) → [[lp:role]] → [[lp:org]] (`type`, `identifier`). The result lives only in the signed `fd_session` cookie: `{sub, customer, role, access, district_keys, school_keys}`.

**Interfaces used.**
| COW node | LP interface | routes |
|---|---|---|
| [[cow:forensics-api]] `/auth/login`, `/auth/callback`, `/auth/logout` | [[lp:sso-oidc-provider]] | `/auth/oidc/authorize`, `/auth/oidc/oauth/token`, `/auth/oidc/userinfo`, `/auth/oidc/logout` |
| [[cow:forensics-auth]] `lookup_scope` | [[lp:oneroster-external-api]] | `/users/{sub}`, `/orgs/{sourcedId}` |

**Gotchas.**
- **`sourcedId` on LP's external API is the LPI.** `external_api_utils.go` rewrites `sourced_id` to `lpi`. That is why passing the OIDC `sub` to `/users/{sourcedId}` works. A real OneRoster sourcedId would 404, and 404 means "no record", which grants no access.
- **The `clientId` claim is the user's LP tenant, not the OIDC client.** COW gates on it through `launchpad_tenants.csv` (one dev tenant mapped to customer `test`).
- **Scope keys are LP `org.identifier` values.** They are compared against forensics unit district/school codes, which are ADAM/state codes. This only works when the LP and ADAM org identifiers match. `launchpad_orgs.csv` is the (empty) override.
- **The role lookup runs on every login with a 10 s timeout.** A failure returns a 502 page. The OIDC callback has no tests.

## ad ↔ oscar

**What it is for.** OSCAR is Pearson's constructed-response scoring platform (human scorers, QC, AI engines). AD does not
score human-scored items mapped to OSCAR itself: at save-off it sends their text to OSCAR, and OSCAR posts the final scores
back. Flow: [[system:score-constructed-response-with-oscar]]. Contracts: [[system:oscar-external-api]] (AD → OSCAR) and
[[system:oscar-score-callback]] (OSCAR → AD). AD side: [[ad:oscar]], [[ad:oscar-scoring]], [[ad:score-with-oscar]];
OSCAR side: [[oscar:external-response-import]], [[oscar:ingest-response]], [[oscar:export-scores]].

**Auth / protocol.**
- AD → OSCAR: per bank, a [[ad:bank-secret]] row `service='oscar'` holds the OSCAR URL, `clientId`/`clientSecret` and
  `metadata.oscar_tenant_ids`. AD calls `POST /v1/auth/token {clientId, clientSecret}` on **every** call (no cache) and sends
  `X-Auth-Token` ([[oscar:auth-token]], [[oscar:external-api-auth]]). The OSCAR auth account's `clients[]` limit which tenants AD may load into.
- OSCAR → AD: `POST {APP_URL}/api/scoring/oscar/callback` ([[ad:human-scoring-api]]) with `ExtScorePayload`. **No authentication**
  on either side (endpoint type `NOAUTH`, [[oscar:score-callback-endpoint]]).

**Entity mirrors.**
| AD | OSCAR | key |
|---|---|---|
| [[ad:response]] | [[oscar:response]] | OSCAR `ref_id` = AD `responses.id` ([[oscar:ref-id]]); OSCAR echoes AD's ids in `meta` (`response_id`, `item_id`, `session_id`, `form_id`) |
| [[ad:oscar-score]] | [[oscar:response]] embedded scores | one row per `ExtScore` in the callback (`score_id`, `score_type`, trait scores); replaced on each callback |
| [[ad:form-oscar-mapping]] | [[oscar:client]] / [[oscar:section]] / [[oscar:item]] | per form and item identifier: `oscar_client_ref`, `oscar_section_ref`, `oscar_item_ref` (+ rubric ref) |
| [[ad:trait]] `external_id` | [[oscar:rubric]] trait `external_id` | must match one-to-one or the callback is rejected |
| [[ad:bank-secret]] `metadata.oscar_tenant_ids` | [[oscar:client]] ref | tenants a bank may read from (enforced on manifest/item/rubric reads only) |

**Interfaces used.**
| AD caller | OSCAR route | when |
|---|---|---|
| [[ad:oscar-api]] | `GET /v1/api/tenant/:t/manifest`, `/item/:ref`, `/rubric/:ref` ([[oscar:external-api]]) | authors map form items to OSCAR in the authoring UI |
| [[ad:oscar-scoring]] `SendOscarResponses` | `PATCH /v1/api/response` (`ExtResponse`: LOCAL text media, `meta`, `labels=[form name]`, `callback_url`) | save-off |
| OSCAR [[oscar:export-scores]] | AD `POST /api/scoring/oscar/callback` | response COMPLETE and exported |

**Flows across the boundary.** Save-off ([[ad:score-session]]) skips local scoring of mapped responses and leaves the session
`SCORE_PENDING` → OSCAR ingests, scores (CF/IEA and/or humans) → callback → `ProcessCallback` writes [[ad:oscar-score]],
[[ad:response-trait-score]] and the [[ad:response]] (including `scoredResponseText`, [[system:ai-score-explanation-to-host]]) →
session re-queued → STM outcomes → [[system:ad-host-callback]].

**Gotchas.**
- **Unauthenticated callback.** Anyone who can reach AD and knows a response UUID can post scores. Every callback is
  authoritative: re-deliveries (rescore, backread, appeal re-export) overwrite earlier scores.
- **Retry rules.** OSCAR treats any non-200 as a failure and re-tries the same payload every **2 h, up to 5 attempts**, then the
  message stalls without an alert. A trait mismatch makes AD answer 400 every time, so all five attempts fail.
- **AD never retries the push.** If `PATCH` fails, AD logs it and scores the response locally as if it were not mapped.
- **PATCH replaces.** Re-sending a response with changed text invalidates the scored OSCAR response (its `ref_id` becomes
  `INV…`) and starts scoring again. AD treats 409 as success but never sees it because it always PATCHes.
- **Ids differ by layer.** OSCAR addresses records by `ref_id` (AD's id), never by its own Mongo `_id`; OSCAR → CF uses yet
  another id (`"<ref>#<oid>"`, see oscar ↔ cf).
- **"Item" is not the same thing.** An OSCAR item is a scoring queue for one question, under tenant → project → section
  ([[oscar:project-section-item]], [[system:item-term]]).
- OSCAR's `callback_url` comes from AD and is used verbatim; the URL is built from AD's `APP_URL`.
- `oscar_scores.payload` is re-marshalled from the typed struct, so OSCAR fields AD does not model are lost.

## adam ↔ oscar

**What it is for.** ADAM sends constructed responses of its own tests (legacy Learnosity question results and QTI item results)
to OSCAR and receives trait scores back on [[adam:external-score-api]]. Same OSCAR contracts as AD:
[[system:oscar-external-api]] and [[system:oscar-score-callback]]. ADAM side: [[adam:oscar]], [[adam:external-scoring]],
[[adam:pass-oscar]].

**Auth / protocol.**
- ADAM → OSCAR: HTTP **Basic** (`OSCAR_USERNAME`/`OSCAR_PASSWORD`, compared in plaintext by OSCAR) on `PATCH {OSCAR_URL}`
  (= `/v1/api/response`); `GET {OSCAR_HOST}/v1/api/tenant/{t}/manifest` for the test's OSCAR-settings picker. Tenants per client in
  `credentials.oscar_tenants` ([[adam:credential]]). `InsecureSkipVerify: true`.
- OSCAR → ADAM: `POST {APP_URL}/v1/api/score`, insecure API group, **no authentication**.

**Entity mirrors.**
| ADAM | OSCAR | key |
|---|---|---|
| [[adam:qti-item-result]] / [[adam:trait-score]] (legacy) | [[oscar:response]] | OSCAR `ref_id` = session `InfoValues`/`AuthValues[test.OscarIDField]` or the session id |
| [[adam:test]] `OscarSettings` (`TenantRef`, `SectionRef`, `ItemRef`, `QuestionID`) | [[oscar:client]] / [[oscar:section]] / [[oscar:item]] | refs per test question |
| [[adam:credential]] `oscar_tenants` | [[oscar:client]] | tenant refs per ADAM client |

**Flows across the boundary.** Save-off finalize ([[adam:saveoff-service]]) or AD sync (parallel 5) → `PATCH /v1/api/response`
with `labels=[admin name]`, `meta` = `AdamOscarMetadata`, `callback_url = APP_URL/v1/api/score` → OSCAR scoring →
`ExtScorePayload` → `OscarScoringService.SaveScore` + `SaveoffService.FinalizeOscarSession`.

**Gotchas.**
- **Who sends for AD-delivered sessions?** A client setting (`shouldSendOscarFromFinalize`) decides whether ADAM sends or AD sends
  (from AD's own form mappings). If both ended up sending, OSCAR would get the answer twice under different `ref_id`s (AD response id
  vs ADAM's id); not seen in practice, inferred from the two code paths.
- **Same path, different services.** OSCAR's own unauthenticated loopback receiver is also `/v1/api/score`; an item with
  `loopback: true` sends to OSCAR itself, not to ADAM.
- **"AdAM resync" in OSCAR** (`POST /v1/admin/adam_resync/:itemId`, [[oscar:item-api]]) only deletes OSCAR's `learnosity_states` for the
  item so [[oscar:learnosity-sweep]] re-pulls legacy ADAM responses from Learnosity. It makes no call to ADAM.
- "A response with this ID already exists" is treated as success. Retry rules are those of [[system:oscar-score-callback]] (2 h, 5 attempts).
- `EnsureQTITraits` no longer creates missing QTI traits, so trait ids must already exist in ADAM.

## oscar ↔ cf

**What it is for.** CF (Continuous Flow, ktcf-continuous-flow) is the AI engine OSCAR calls **IEA** (also CF, KT,
ktscoringservices; [[system:iea-term]]). OSCAR sends responses of IEA-enabled items to CF, CF returns machine scores, LID
(learner-in-danger) alerts and requests for more human reads, and OSCAR reports human/QC scores back so CF can train and report.
OSCAR side: [[oscar:iea]], [[oscar:iea-integration]], [[oscar:iea-scoring]], [[oscar:iea-callback-api]]; CF side: [[cf:oscar]],
[[cf:client-api]], [[cf:external-client-driver]]. Only CF's External Client API binary is mapped; provisioning goes to
[[cf:system-integration]] (out of scope).

**Auth / protocol.**
- OSCAR → CF: per OSCAR tenant, a CF client key/secret ([[cf:cf-client]], created by CF `CreateTenant`, stored by OSCAR as a credential)
  exchanged at `POST /v1/auth` for `X-Auth-Token`. OSCAR assumes the token lasts 30 min and re-auths on 401; CF expires it after
  30 min **idle**.
- CF → OSCAR: [[cf:external-client-driver]] posts to `tags.outcomeEndpoint` = OSCAR `CALLBACK_URL/iea/v1`, after
  `POST /iea/v1/auth {clientToken, clientSecret}`. Credentials are attached only when a matching `CFClientApiConfig_<endpoint>` secret exists.

**Entity mirrors.**
| CF | OSCAR | key |
|---|---|---|
| [[cf:cf-response]] | [[oscar:response]] | CF `testId` = OSCAR `responseId` string `"<ref_id>#<_id>"` |
| [[cf:qc-score]] | [[oscar:qc-score]] | `responseId` `"<oid>#<ref>"` (order reversed) |
| [[cf:cf-prompt]] | [[oscar:item]] | `item.cf_config.item_id` sent as `itemId`, an alias on the prompt ([[cf:item-alias]]) |
| [[cf:cf-admin]] ([[cf:admin-name]]) | [[oscar:project]] | `project.iea_project.projectId` sent as `adminName` |
| [[cf:lid]] | [[oscar:lid]] | LID alert raised by CF, reviewed in OSCAR |
| [[cf:iea]] | [[oscar:cf]] | same engine, different names |

**Interfaces used.**
| direction | route | OSCAR call / CF sender |
|---|---|---|
| OSCAR → CF | `POST /v1/auth` | token |
| OSCAR → CF | `POST /v1/response` | `RequestScore` ([[cf:response-intake-and-scoring]]) |
| OSCAR → CF | `POST /v1/outcome`, `PATCH /v1/outcome` | `ReportHumanScore`, `SmartSortCodeReviewed`; `NotifyScoreInvalidated` ([[cf:human-outcome-intake]]) |
| OSCAR → CF | `POST /v1/outcome/lid` | `LidReviewed` ([[cf:lid-review]]) |
| OSCAR → CF | `POST /v1/response/release` | `NotifyResponseExported` ([[cf:response-export-release]]) |
| OSCAR → CF | `/v1/system_integration/*` | tenant, project, item, TOD, smart-sort, field-test, modeling ([[cf:system-integration]]) |
| CF → OSCAR | `POST /iea/v1/outcome` | `SendScore` (machine score, `scoredResponseText` in tags) |
| CF → OSCAR | `POST /iea/v1/outcome/lid` | `SendLidScore` |
| CF → OSCAR | `POST /iea/v1/score/request` | `RequestAdditionalHumanScores` (priority, double read) |
| CF → OSCAR | `POST /iea/v1/workflow/state` | TOD item modeling state changes |

**Flows across the boundary.** [[oscar:iea-scoring]] → CF scoring → outcome back; human/QC reads → CF; export → release.
Part of [[system:score-constructed-response-with-oscar]] and [[system:ai-score-explanation-to-host]].

**Gotchas (mismatches found in code, cf README › Known risks 9-13).**
- **QC true-score invalidation always fails.** OSCAR sends `outcomeId:""` and no `scoreType`; CF requires `outcomeId`.
- **Smart-sort reviews may be rejected.** `SmartSortCodeReviewed` posts `/v1/outcome` with no trait scores; CF requires one per
  prompt trait when the prompt defines any.
- **OSCAR calls routes CF does not have:** `/v1/tenant/{t}/project|item/{id}` and `…/workflow_state`; `/v1/test_responses` is an
  unimplemented OSCAR stub.
- **RMS images.** CF rejects RMS clip URLs unless `allowRmsImageUrls` is set on the prompt/admin, whatever OSCAR's `send_images` says.
- **Host routing unverifiable.** OSCAR sends every call to `oscar.<env>.ktscoringservices.com`, which in the CF repo is the
  system_integration binary (no `/v1/auth`, `/v1/response`, `/v1/outcome`); the ingress lives outside both repos.
- **Two id orders.** Responses are `"<ref>#<oid>"`, QC scores `"<oid>#<ref>"`; CF matches `testId` exactly, so a QC outcome needs an
  earlier `/v1/response` with the same string or CF answers 400.
- **Trait names.** OSCAR maps CF trait names `"0"` / `"1".."n"` to rubric traits; CF prompts must be provisioned with numeric names.
- **Silent 200s.** CF acks `POST /v1/response` before scoring and drops dispatch errors; `PATCH /v1/outcome` and `/v1/outcome/lid` return an
  empty 200 when alias resolution fails.
- **No authorization in CF.** Any CF client can act on any admin/item; `tags.outcomeEndpoint` chosen by the client decides where outcomes go.
- Low-confidence outcomes are kept only as `meta.machine_score` unless the OSCAR item sets `accept_iea_low_conf`.
- Re-posting a response to CF replaces `tags`, dropping `scoredResponseText` until the next scoring.

## ad → mre

**What it is for.** AD's QTI engine scores math constructed responses through the Math Response Engine (ktmath-scoring)
whenever an item's response processing has a `pearson.mre` custom operator. The item carries its own scoring definition, so
MRE scores what AD sends. Flow: [[system:score-math-response-with-mre]]; capability [[system:math-auto-scoring]].
AD side: [[ad:mre]], [[ad:qti-engine]]; MRE side: [[mre:stateless-score-from-ad]], [[mre:scoring-api]].

**Auth / protocol.** `POST {MRE_HOST}/ws/stateless` (default `https://scoring.math-content.pearsonkt.com`), JSON, 210 s timeout.
Credentials in the body: `clientUUID` = `MRE_CLIENT_ID` (default `AD`), `clientSECRET` = `MRE_SECRET`, checked against salted
SHA-256 hashes by [[mre:client-auth]]. MRE always answers HTTP 200 with a status code.

**Entity mirrors.**
| AD | MRE | key |
|---|---|---|
| QTI `pearson.mre` operator `itemDefinition` on [[ad:item]] | [[mre:scoring-item]] | sent inline; `itemUUID` = QTI item identifier (not a UUID) |
| [[ad:response]] (record-cardinality value) | [[mre:candidate-response]] | `{location, values:[{identifier, content}]}` |
| [[ad:session]] | [[mre:request-archive]] `testUUID` | AD session id; `requestUUID` new per call; delivery only |
| [[ad:session]] `admin_code` | request `admin` | AD-5990; logging and `activeAdministration` only |

**Interfaces used.** [[mre:scoring-api]] `/ws/stateless` only (not `/ws/rest`, which scores stored items).

**Flows across the boundary.** Every AD scoring path: delivery `SaveResponse`, save-off, the adaptive driver, preview
(`GenericScore`) and item test cases. Preview and test cases send no `requestUUID`/`testUUID`/`admin`, so MRE archives nothing.
All hosts that deliver AD forms are affected indirectly (ADAM, ARC, Navvy).

**Gotchas.**
- **Silent zeros.** Status 0/1 → score; status 2 or HTTP ≥ 500 → retry twice (1 s × attempt); anything else, including a bad secret
  (status 5) or unknown pattern (6), is logged and the operator scores **0**. An outage scores every MRE item 0.
- `MRE_SECRET` defaults to empty, so an environment without it fails every call.
- Retries reuse the `requestUUID`, so MRE's archive can hold several documents per request.
- AD's standalone [[ad:scoring-api]] has no MRE context and always scores `pearson.mre` items 0.
- MRE has a hard-coded `secretClientId` that skips the secret check ([[mre:secret-client-id]]); AD does not use it.
- MRE is not CF/IEA. Abigail's [[abigail:mre-reserved-metadata]] (grades on math items) is read by ktmath authoring, not by MRE.

## ad → xprt (and ABBI → xprt)

**What it is for.** XPRT composes print-ready PDFs of QTI content on a Typefi (InDesign Server) back end. AD calls it for items,
stimuli and forms; Abigail (and ADAM's legacy UI) reach it only through AD's `/api/data/xprt/*` routes. Flow:
[[system:print-form-via-xprt]]; capability [[system:print-composition]]. AD side: [[ad:xprt]], [[ad:xprt-orchestration]],
[[ad:xprt-api]], [[ad:print-with-xprt]]; XPRT side: [[xprt:ad]], [[xprt:xprt-api]], [[xprt:submit-item-for-print]], [[xprt:poll-print-status]].

**Auth / protocol.** Multipart HTTP. AD signs an HS256 JWT over `{jobId, iat}` with `XPRT_KEY`; XPRT verifies it with
`xprt_AD_shared_key` (iat ±300 s, `jobId` claim = path). The token goes in the form body, not a header. XPRT never calls AD's API:
the only thing it sends back is the PDF, PUT to an S3 URL AD presigned in `AD_S3_INTERNAL`.

**Entity mirrors.**
| AD | XPRT | key |
|---|---|---|
| [[ad:job-history]] (`xprt_print`) | [[xprt:job]] | AD job id = XPRT `abbiJobId` path GUID = JWT claim ([[xprt:abbi-job-id]]) |
| [[ad:item]] / [[ad:shared-stimulus]] / [[ad:form]] | [[xprt:xprt-package]] | `uin` = identifier, `assetRefId` = entity id |
| [[ad:s3-buckets]] `AD_S3_INTERNAL/<entityID>` | [[xprt:s3-buckets]] / [[xprt:s3-upload]] | PDF written by presigned PUT (1 h) |
| AD account and bank **names** | Typefi AD mapping ([[xprt:workflow-routing]]) | `account`, `bank`, `print_formtype` pick the workflow |

**Interfaces used.** `POST {XPRT_URL}/xprt/api/item/v1/AD/submit/{jobId}` (zip, `uin`, `assetRefId`, `creator=AD`, `preSignedURL`,
`account`, `bank`, `print_formtype`, `jwt`) → 202 `{typefiJobId}`; `POST .../item/v1/AD/polling/{jobId}` (`jwt`) → 204 still composing,
200 `{"status":"200"}` done, `{"xprtError"}` failed.

**Flows across the boundary.** Abigail `print_start` → AD eligibility + job → QTI export → submit → poll every 5 s for 2 min → XPRT PUTs PDF →
browser fetches a presigned GET. Item, stimulus and form saves invalidate the PDF.

**ABBI → XPRT.** XPRT's original client is **ABBI**, Pearson's older asset/item-banking system ([[xprt:abbi]],
[[xprt:abbi-print-preview]]). ABBI uses the `/item/submit` (v1), `/item/v2` and `/item/v3` routes (v3 with its own HS256 key),
picks workflows by program/subject/grade and receives PDFs in its own S3 bucket. ABBI is **not** Abigail, has no graph here, and
shares nothing with AD except the XPRT service.

**Gotchas.**
- AD has a hard-coded default `XPRT_KEY` in `options.go`; an environment without the env var signs with a committed secret.
- AD stops polling after 2 min while XPRT keeps composing; a slow Typefi fails AD jobs that later succeed.
- Clock skew over 300 s breaks submit and poll (AD reuses the submit token for all polls).
- Renaming an AD account or bank breaks printing until the Typefi mapping is updated (500 "not included in the mapping").
- No timeouts on AD's submit; AD logs the signed JWT at info level; `xprt_print` jobs are never cleaned up.
- The PDF key is the entity id, so a new print overwrites the old one.

## adam → mot

**What it is for.** MOT (Materials Ordering and Tracking) fulfils paper test materials. ADAM is an ordering front end: it reads
MOT programs and distributions, posts Initial/Additional orders and Pre-ID student rosters, and polls order status and carrier
tracking. MOT does not call ADAM. Flow: [[system:materials-ordering-from-adam]]; capability [[system:paper-materials-ordering]].
ADAM side: [[adam:mot]], [[adam:order-service]], [[adam:place-materials-order]]; MOT side: [[mot:mot-orders-api]],
[[mot:mot-tracking-api]], [[mot:test-admin-api]], [[mot:program-api]].

**Auth / protocol.** REST/JSON. OAuth2 client credentials (`MOT_CLIENT_ID`/`SECRET`, `MOT_TOKEN_URL`), token cached in Redis
`mot_oauth_token` for 30 min. Four base URLs: `MOT_CONFIG_URL`, `MOT_ORDERS_URL`, `MOT_TRACKING_URL` and the token URL. Some routes
are called without a token: `/api/progConfig/getProgram`, `/api/trackdetailshistorybynum`, `/api/tracknumsbyprogram`.
Tracking parameters travel as request headers.

**Entity mirrors.**
| ADAM | MOT | key |
|---|---|---|
| [[adam:mot-order]] (`mot_orders`) | [[mot:order]] | ADAM `order_number` sent as `customerOrderId`; status read back by it |
| [[adam:mot-order]] (`mot_distributions`) | [[mot:distribution]] | `distribution_id`, `distribution_code`, `orders_type` (I/A) |
| [[adam:mot-pre-id-student]] | MOT student lines ([[mot:student-mapping]], [[mot:student-error]]) | one line per session; barcode = ADAM [[adam:pre-id]] |
| [[adam:mot-order]] `record_type_code` | [[mot:distribution-record-type]] | per distribution code ([[system:record-type-term]]) |
| [[adam:admin]] | [[mot:test-administration]] | by program number, not by id ([[system:admin-term]]) |

**Interfaces used.** Orders service: distributions by program / name / code, `getDistributionsDetails`, `getDistributionsByProgramContains`,
`getEnrollmentBucketsByDistributionCode`, `getMaterialItemsAO`, `getCustomerAttributes`, `getRecordTypesByDistributionCode`,
`postOrder`, `student/postStudent`, `getOrderStatusByCustomerOrderId`, `motOrder/orderdetails`, `/api/orderdetails`. Tracking service:
`trackdetailshistorybynum`, `tracknumsbyprogram` (`systemname: ADAM`), `tracknumsbycdspage`, `getCarrierCodes`. Config:
`testAdmin/getProgramNumber`, `progConfig/getProgram`. The order-side routes ADAM uses are not called by the MOT UI and are listed
in [[mot:mot-orders-api]] / [[mot:mot-tracking-api]] › "Routes called by adam".

**Flows across the boundary.** Daily distribution sync ([[adam:mot-distribution-sync]]); order create → optional approval → `postOrder`;
Pre-ID → `postStudent`; hourly status sync ([[adam:mot-order-status-sync]]); hourly/daily import of orders placed in MOT directly
([[adam:mot-external-order-sync]]). In MOT the order is staged, batched, approved and exported ([[mot:orders-status-batching]]).

**Gotchas.**
- **The MOT Orders and Tracking services are not in the mapped repos.** MOT's graph covers the config API and the SPA; ADAM's
  routes are documented only from ADAM's client.
- A failed `postOrder` is stored as `Failed to order` and the API still answers 200; nothing resends.
- A new Pre-ID order deletes the administration's previous one in ADAM before posting.
- No HTTP timeout; 429 retried 3 times at 1 s, with a nil-response dereference on retry network errors.
- ADAM logs the MOT token, and a failed token call dumps the request (with `client_secret`) to the log.
- MOT also accepts **ADAM student files** uploaded by hand ([[mot:upload-orders]]): a manual hand-off next to `postStudent`.
- MOT "rollover" (new administration or distribution) does not touch ADAM; ADAM sees new distributions at the next daily sync ([[system:rollover-term]]).

## psaes ↔ adam

**What it is for.** PSAES is Pearson's paper scan-program and SRL layout system. It shares two codes with ADAM today, and ADAM
has built a lookup API for PSAES that PSAES does not call yet. Contract (planned): [[system:adam-psaes-lookup-api]]; ADAM side
[[adam:psaes]], [[adam:psaes-lookup]] (status planned), [[adam:external-lookup-api]].

**Auth / protocol (planned).** `POST /v1/api/orgs/lookup` and `POST /v1/api/barcodes/lookup` on ADAM, `X-Auth-Token` = environment JWT
signed with `JWT_LOOKUP_SIGNINGKEY`, `aud=psaes`, `sub` = caller name ([[adam:environment-token]]). One token per ADAM environment; results
span every tenant; per-token rate limits (120/min default, 429 + `Retry-After`); one audit log line per call.

**Id mapping (live today, field-level only).**
| PSAES | ADAM | note |
|---|---|---|
| [[psaes:account]] `account_code` ([[psaes:account-code]]) | [[adam:client]] `customer_code` | ADAM lower-cases; PSAES keeps case and compares with `upper()` |
| [[psaes:test-admin-system-code]] `code` (`system_code='ADAM'`) | [[adam:admin]] `admin_code` | stored lower-cased and trimmed; replaced the old `adam_admin_code` column |
| scanned Pre-ID barcode | [[adam:pre-id]] (`users.barcode`) | printed on documents ordered through MOT; lookup planned |

**Gotchas.**
- **Planned, not live.** No HTTP client or ADAM URL exists in esuite-psaes-backend or esuite-psaes-gridfe, so there is no `calls` xref.
  Open questions in ADAM's contract doc: shipping/contact fields, full vs incremental pulls, inactive orgs.
- The codes are shared by convention only; neither app references the other's rows ([[system:client-term]]).
- PSAES "eligibility" (forms an administration covers) and "record type" (scanner layout) are not ADAM's or MOT's meanings
  ([[system:eligibility-term]], [[system:record-type-term]]).
- PSAES's MOT query in `HomeRepository` is dead, so there is no psaes ↔ mot link. PASS (not in this graph) pulls SRL layouts from
  PSAES ([[psaes:pass-srl-extract]]) and scans the Pre-ID documents.

## cow → navvy

**What it is for.** Navvy is becoming COW's second upstream source next to ADAM (COW-4550). Only the plumbing exists: COW Glue jobs
can reach a read-only Navvy Postgres reader. No extract, ingest or warehouse table reads Navvy yet. COW node: [[cow:navvy-db-connection]];
Navvy datastore [[navvy:postgres]].

**Auth / protocol.** Direct JDBC to `navvy-cow-ro` (us-east-2, navvy-prd account). One Secrets Manager secret per COW AWS account, picked
by account id at runtime (`NAVVY_SECRET_ARN_BY_ACCOUNT`); Glue args `--NavvySecretArn`/`--NavvyHost`/`--NavvyPort`/`--NavvyDBName` with
sentinel `auto`. Network routes from both COW accounts were set up by SRE outside the repos.

**Entity mirrors.** None yet.

**Gotchas.**
- Both COW accounts read **Navvy prd**; there is no non-prod Navvy, so dev/tst/smk jobs see real data.
- The connectivity-check job (`navvy_connection_test.py`) is not registered in ETLStack and runs ad hoc on the ADAM Glue connection.
- An unknown AWS account fails fast with `KeyError` (e.g. sandbox).
