# PRD — Click Foundation - LTI Integration Tool

Evidence date: 2026-10-19. Source: `git@bitbucket.org:overdrive/click-foundation-lti-integration-tool.git`, `master` at `fbf20a6` (2022-05-30, "Initial commit"). Only branch on the remote.

There is no prior PRD in Silicon Ark. The repository documents itself as the TAO LTI 1.3 DevKit (`README.md`, `doc/installation.md`, `doc/api.md`, `doc/cli.md`, `doc/openapi/devkit.yaml`). Requirements below come from those documents and from the code that implements them. Provenance is marked on each item.

The Bitbucket project name says Click Foundation. The application code does not describe a Click Foundation product beyond naming the configured platform "Click Learner Portal Platform". That naming is **Implemented**. A product decision that this repository is the Click learner-portal integration is **Unknown / Requires Confirmation**.

A separate Silicon Ark set, `click-learner-portal-platform`, documents another codebase that uses the same platform display name, with client id `click-learning` and audience `https://clms.clicklearning.org/platform`, and launches TAO Ignite. This repository does not contain that client id or host. The two documents must not be read as one system until someone confirms the link.

## Product purpose

An operator, or a calling system, uses this application to:

- act as an LTI 1.3 **platform** that launches a tool and serves AGS, NRPS, ACS, and Basic Outcome;
- act as an LTI 1.3 **tool** that receives those launches and calls another platform's services;
- generate launch URLs over HTTP or the console without using the HTML forms.

Provenance: **Documented** (`README.md`: "to act as platform and / or tool"). The screens and routes that do this are **Implemented**.

The README says the kit is IMS certified for LTI 1.3 Advantage complete and for LTI 1.3 proctoring services, and links the TAO certification record. That certification statement is **Documented** for the upstream DevKit. It is not evidence that a Click Foundation deployment is certified.

## Users and roles

There is no login for human operators. The HTML UI sits on the `main` firewall with `anonymous: lazy` and an empty in-memory user provider (`config/packages/security.yaml`). Anyone who can open the host can use the dashboard, launch forms, CRUD screens, the tool service client, and `/php-info`.

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Anonymous operator | Uses the HTML UI | `security.yaml` firewall `main` | Implemented |
| API caller | Calls `/api/...` with `Authorization: Bearer <APP_API_KEY>` | `doc/api.md`, `ApiKeyListener`, `ApiKeyProvider` | Documented. See BR for a gap when the header is missing |
| LTI platform | Starts OIDC and posts messages to the tool | Tool firewall `^/tool/launch`, registration `local` | Implemented |
| LTI tool | Calls platform services and returns deep-linking or proctoring messages | Platform service firewalls and `/platform/message/return` | Implemented |
| Configured launch user | A fictional person selected for a launch or listed in the default NRPS membership | `config/devkit/users.yaml` | Implemented as demo data |

`config/devkit/users.yaml` defines 13 users. Seven have the IMS instructor role (`http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor`): han, leia, jabba, obiwan, sidious, vader, yoda. Six have the learner role (`...#Learner`): c3po, chewbacca, fett, luke, maul, r2d2. Names and emails are fictional. They are not a Click Foundation roster.

Launch identity is not a password login. `UserAuthenticator` reads a JSON login hint:

- `type` `list` loads id, name, email, and profile fields from `users.yaml`.
- `type` `custom` uses id, name, email, and locale from the hint. Missing id is not filled here; the message builder supplies a UUID before this path when the API omits `user.id`.
- Any other type, including `anonymous` (used when the API omits `user`), returns success with no user identity.

Provenance: **Implemented** (`src/Security/User/UserAuthenticator.php`, `LtiMessageBuilder::getLoginHint`).

## Registration in force

One registration is configured. Identifier: `local`. Client id: the literal `common_client_id_value`. Deployment id: `deploymentId1`.

| Side | Name | Audience and URLs |
|---|---|---|
| Platform `localPlatform` | Click Learner Portal Platform | Audience `http://127.0.0.1:8000/platform`. OIDC authentication `http://127.0.0.1:8000/lti1p3/oidc/authentication`. Token `http://127.0.0.1:8000/lti1p3/auth/platformKey/token`. JWKS `http://127.0.0.1:8000/lti1p3/.well-known/jwks/platformSet.json` |
| Tool `devkitTool` | LTI 1.3 DevKit (as tool) | Audience `http://127.0.0.1:8001/tool`. OIDC initiation `http://127.0.0.1:8001/lti1p3/oidc/initiation`. Launch and deep linking `http://127.0.0.1:8001/tool/launch`. JWKS value as parsed: `http://127.0.0.1:8001/lti1p3/.well-known/jwks/deliver.json"` (trailing quote character included) |

Both key chains read the same `config/keys/public.key` and `config/keys/private.key`. The platform key set name is `platformSet`. The tool key set name is `toolSet`. The tool JWKS URL path says `deliver.json`, not `toolSet.json`.

`APP_HOST` in `.env` is `http://devkit-lti1p3.localhost`, which is also the host in `doc/installation.md` and the OpenAPI server URL. Service claim URLs in the running container are built from `APP_HOST`. The registration audiences are not.

Provenance: **Implemented** (`config/packages/lti1p3.yaml`, `.env`). CLI examples in `doc/cli.md` pass `"registration": "devkit"`. That identifier is not in `lti1p3.yaml`. Using the documented example against this checkout fails the builder check for an unknown registration. **Documented** example versus **Implemented** configuration. **Requires Confirmation** which host and registration a real Click launch uses.

## Main workflows

### W1 — Open the dashboard

Trigger: a browser requests `GET /`.

`DashboardAction` renders `dashboard/dashboard.html.twig` with the resolved LTI configuration, the user catalogue, and cache counts from `PlatformStatisticsCollector`.

Counts: cached NRPS memberships plus 1 (the default membership is not stored in Redis), basic outcomes, AGS line items, scores, results, ACS assessments, and ACS controls.

Provenance: **Implemented**.

### W2 — Launch an LTI resource link from the platform UI

Trigger: the operator submits the resource-link form at `GET` or `POST` `/platform/message/launch/lti-resource-link`.

The form requires a user-list choice. Custom user id, name, email, locale, launch URL, and claims are optional. If no resource-link claim id is supplied, `LtiMessageBuilder` generates a UUID. The selected registration must exist. The builder returns an OIDC initiation URL the browser can open.

Provenance: **Implemented** (`LtiResourceLinkLaunchType`, `LtiMessageBuilder`).

### W3 — Deep linking

Trigger: platform form ` /platform/message/launch/deep-linking`.

The form requires accepted types, accepted presentation targets, accept-multiple, and auto-create. Defaults in the API builder, when settings are omitted, are accept type `ltiResourceLink`, target `windows`, accept multiple true, auto create false. The deep-linking return URL is the route `platform_message_deep_linking_return` (`/platform/message/return/deep-linking`).

The tool receives the launch at `/tool/launch`, and the operator posts a content selection to `POST /tool/deep-linking-response`. Selectable examples are configured in `config/devkit/deep_linking.yaml`: an LTI resource link back to this tool, an image, a Wikipedia link, a PDF file, and an HTML fragment. Several of those URLs point at third-party hosts (TAO, Wikipedia, and others).

Provenance: **Implemented**.

### W4 — Proctoring (start, assess, end)

Trigger: platform form `/platform/message/launch/proctoring`.

Required on the form: a user-list choice, start-assessment URL, and attempt number. Launch URL is optional and falls back to the registration tool launch URL. The help text tells the operator to use `/platform/message/return/proctoring` as the start-assessment URL when the devkit is both sides.

The tool posts `POST /tool/proctoring-response`. The platform return route is `/platform/message/return/proctoring/{identifier}` (identifier may be empty). Ending an assessment is `POST /platform/message/end/proctoring`.

ACS assessments are CRUD records with identifier and status. Allowed statuses, from the proctoring library constant used by the form: `none`, `running`, `paused`, `terminated`, `complete`.

The ACS service is `POST /platform/service/acs/{assessmentIdentifier}` and requires scope `https://purl.imsglobal.org/spec/lti-ap/scope/control.all`.

Provenance: **Implemented**.

### W5 — Submission review

Trigger: platform form `/platform/message/launch/submission-review`, or API/CLI type `LtiSubmissionReviewRequest`.

The form requires AGS scopes and an AGS line item URL. Submission owner fields are optional. If the API omits `submission_owner`, the builder uses the launch user when present, otherwise a generated id, and the learner role. If `user` is also omitted, the for-user claim is a new UUID only.

`ags_scopes` and `ags_line_item_url` are read without a default. Omitting them is an error in the builder (undefined index). The CLI doc marks both required. Provenance: **Documented** and **Implemented**.

### W6 — Generate a launch without the UI

Trigger: `POST /api/platform/messages/{messageType}/launch`, or `php bin/console devkit:create:message:launch`.

Accepted message types, after `ucfirst` of the path segment or the `--type` value:

- `LtiResourceLinkRequest`
- `LtiDeepLinkingRequest`
- `LtiStartProctoring`
- `LtiSubmissionReviewRequest`

Any other type returns HTTP 400 with `Invalid message type ...` on the API. Invalid JSON returns HTTP 400 with the `json_decode` error. Query `verbose=true` adds URL and parameter details. The CLI `-v` flag does the same.

`registration` is required. A missing value throws `Missing registration`. An unknown value throws `Invalid registration {id}`. Those exceptions are not HTTP exceptions, so the API error subscriber responds with HTTP 500 and a JSON `error` string.

`proctoring_start_assessment_url` is required for start-proctoring (no default; missing key is a PHP notice or error depending on version). Attempt number defaults to 1.

If `user` is omitted, the login hint type is `anonymous`. If `user.id` is omitted, the builder generates a UUID v4.

Provenance: **Documented** (`doc/api.md`, `doc/cli.md`, OpenAPI) and **Implemented** (`CreateMessageLaunchAction`, `CreateMessageLaunchCommand`, `LtiMessageBuilder`).

### W7 — Manage platform records in the UI

Trigger: operator opens the list and create/edit screens.

| Area | List | Create | View | Edit | Delete |
|---|---|---|---|---|---|
| NRPS memberships | `GET /platform/nrps/memberships` | `GET`+`POST /platform/nrps/memberships/new` | `GET .../view` | `GET`+`POST .../edit` | `GET .../delete` |
| ACS assessments | `GET /platform/proctoring/assessments` | `.../new` | `.../view` | `.../edit` | `GET .../delete` |
| AGS line items | `GET /platform/ags/line-items` | `.../new` | `.../view` | `.../edit` | `GET .../delete` |
| Basic outcomes | `GET /platform/basic-outcomes` | — | — | — | `GET /platform/basic-outcomes/{id}/delete` |

NRPS form requires membership identifier and context identifier. Context label, context title, and JSON members are optional.

AGS line item form requires identifier, context identifier (create only), label, and score maximum. Resource id, resource link id, tag, start date, and end date are optional. On edit, the identifier field is disabled.

Deletes are HTTP GET and then redirect back to the list with a flash success or error. A missing record throws `NotFoundHttpException`.

The default NRPS membership (identifier `default`, context identifier `default`) is built from `users.yaml` on each list and is not loaded from Redis. The delete action only deletes a membership found in `MembershipRepository`, so the default row is not removed by that delete route. Provenance: **Implemented**.

### W8 — Call platform services as a tool

Trigger: an LTI tool, or the devkit's own service client at `/tool/service/client`, calls the platform.

| Service | Route | Method | Scope |
|---|---|---|---|
| NRPS membership | `/platform/service/nrps/{contextIdentifier}/memberships/{membershipIdentifier}` | GET | `https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly` |
| ACS | `/platform/service/acs/{assessmentIdentifier}` | POST | `https://purl.imsglobal.org/spec/lti-ap/scope/control.all` |
| Basic Outcome | `/platform/service/basic-outcome` | POST | `https://purl.imsglobal.org/spec/lti-bo/scope/basicoutcome` |
| AGS list / create line items | `/platform/service/ags/{contextIdentifier}/lineitems` | GET, POST | lineitem, lineitem.readonly, score, result.readonly (all four listed on the firewall) |
| AGS get / update / delete line item | `.../lineitems/{lineItemIdentifier}` | GET, PUT, DELETE | same |
| AGS scores | `.../scores` | POST | same |
| AGS results | `.../results` | GET | same |

NRPS `membershipIdentifier` `default` uses the factory membership. Any other identifier is loaded from Redis. If the membership is missing, or its context id does not equal `{contextIdentifier}`, the server throws not found. Optional query filtering by `role`, `limit`, and `offset` is implemented; a next-link relation is added when a page remains.

The tool UI also has AJAX helpers under `/tool/ajax/...` for NRPS, Basic Outcome, ACS, and AGS. Those routes require `request.isXmlHttpRequest()`.

Provenance: **Implemented** (`config/routes.yaml`, `config/packages/security.yaml`, `MembershipServiceServerBuilder`).

### W9 — Manage NRPS and assessments over the API

NRPS:

- `GET /api/platform/nrps/memberships`
- `GET /api/platform/nrps/memberships/{membershipIdentifier}`
- `POST /api/platform/nrps/memberships`
- `PATCH /api/platform/nrps/memberships/{membershipIdentifier}`
- `DELETE /api/platform/nrps/memberships/{membershipIdentifier}`

Assessments: the same five operations under `/api/platform/proctoring/assessments`.

OpenAPI (`doc/openapi/devkit.yaml`, version v2.4.2) is the documented contract, including 400, 401, and 500 responses. Provenance: **Documented** and **Implemented** as routes.

## Functional requirements

### FR-001 — Dual role

The system can be configured as an LTI platform and as an LTI tool at the same time.

Evidence: `lti1p3.yaml` platforms, tools, and registrations; README. Implementation: Implemented. Confidence: High.

### FR-002 — Four platform launch types

The system can generate resource-link, deep-linking, start-proctoring, and submission-review launches from the UI, the API, and the CLI.

Evidence: routes, `LtiMessageBuilder`, `doc/cli.md`, OpenAPI. Implementation: Implemented. Confidence: High.

### FR-003 — Registration must exist

Launch generation refuses a missing or unknown registration identifier.

Evidence: `LtiMessageBuilder::getRegistration`. Implementation: Implemented. Confidence: High.

### FR-004 — Operator UI is anonymous

The HTML application does not authenticate the operator.

Evidence: `security.yaml` firewall `main`. Implementation: Implemented. The docs do not state this. Confidence: High.

### FR-005 — API key on HTTP API

Documented rule: every HTTP API request sends `Authorization: Bearer` with `APP_API_KEY`. A presented key that does not match returns HTTP 401 and JSON `{"error":"Unauthorised api key"}`.

Evidence: `doc/api.md`, `ApiKeyProvider`. Implementation: Implemented for a present, wrong key. Behaviour when the header is absent is not settled by the listener (it returns without a response) and there is no `access_control` rule. Confidence: Medium. See business rules.

### FR-006 — Service calls use LTI scopes

Platform service routes are behind `lti1p3_service` firewalls with the scopes listed in W8. Message launch and return routes use `lti1p3_message_tool` and `lti1p3_message_platform`.

Evidence: `security.yaml`. Implementation: Implemented. Confidence: High for the configuration. Token failure behaviour is inside the OAT bundle and was not re-tested here.

### FR-007 — Demo records live in Redis

AGS line items, scores, and results, NRPS custom memberships, basic outcomes, and ACS assessments are stored in the Redis app cache. There is no database migration and no Doctrine dependency.

Evidence: `cache.yaml`, repository cache keys. Implementation: Implemented. Confidence: High.

### FR-008 — Default class membership

A membership with identifier `default` and context `default` is always available and mirrors `users.yaml`. Members are status active.

Evidence: `DefaultMembershipFactory`, `MembershipServiceServerBuilder`, dashboard count `+ 1`. Implementation: Implemented. Confidence: High.

### FR-009 — Health endpoints

`GET /ping` and `GET /health-check` are registered from `oat-sa/bundle-health-check`.

Evidence: `config/routes/health_check.yaml` and the bundle route files. Implementation: Implemented as routes. What the check asserts beyond the bundle default was not opened in full. Confidence: Medium.

### FR-010 — Errors

API failures become JSON `{"error": "..."}`. Status is the HTTP exception status, or 500 otherwise. The message is logged. Non-API failures render `templates/error`.

Evidence: `src/EventSubscriber/ErrorHandlerSubscriber.php`. Implementation: Implemented. Confidence: High.

## Integrations

| System | Purpose | Direction | Auth | Failure |
|---|---|---|---|---|
| Remote LTI tool | Receives launches from this platform | Outbound launch URL | OIDC / LTI JWT signed with the platform private key | Not specified in app code beyond the launch URL being returned to the caller |
| Remote LTI platform | This app acting as the tool | Inbound to `/tool/launch` and outbound service calls from the tool client | Tool key chain, service scopes | Tool client UI shows the service response; exact error copy was not fully traced |
| Redis | Cache for demo entities | Outbound | `REDIS_CACHE_DSN` | Not wrapped with a product-level retry in the repositories. A down Redis fails the cache call |
| IMS specification set | Protocol the messages follow | N/A | N/A | N/A |
| Third-party sample URLs | Deep-linking examples and user pictures | Outbound browser fetches | None | Broken sample URLs would show broken images or links. Not a product integration |

No payment, email, SMS, or Click learner-portal API client is present.

## Constraints

- Licence on the project is GPL-2.0-only (`composer.json`, `LICENSE`). Source file headers say GPL version 2, non-upgradable, copyright OAT.
- PHP `>=7.2` with ctype, iconv, and json. Docker uses PHP 8.
- Locale is English only (`config/packages/translation.yaml`). No application catalogues were found under `translations/`.
- CSRF protection is commented out in `config/packages/framework.yaml`.
- Session cookie is `SameSite=lax` and `cookie_secure: auto`.
- Nginx `client_max_body_size` is 8m (`docker/nginx/nginx.conf`).
- UI deletes are GET, so a browser or prefetch can trigger them. Provenance: **Implemented**. Whether that is acceptable is **Requires Confirmation**.
- Secrets and the private key are stored in Git. See technical documentation. Values are intentionally omitted here.

## Behaviour that could not be established

- Whether Click Foundation runs this app, and against which learner portal.
- Production URL, TLS, and who holds the real client id and deployment id. The checked-in values are local and literal placeholders.
- Whether the malformed tool JWKS URL prevents tool authentication.
- Runtime result of an `/api/` call with no `Authorization` header.
- Whether `ApiKeyFactory` replacing the provider's first constructor argument with the user provider breaks key checks. The class expects a parameter bag. This was not executed.
- Customer-facing workflows beyond LTI testing. None are described.
