Living Course internals
The package api/packages/living-course (Ulams\LivingCourse) builds on the Course Builder. Decisions are recorded in ADRs 0030 to 0034; the operator view is in Living Course settings.
- A connector fetches a source (upload, Git repository, web pages) and the course builder’s own ingestion turns it into fragments with stable ids and a
file_path. Each fetch that differs from the last one becomes a revision (numbered per source; raw files are kept on the private disk). - Change detection is deterministic and costs nothing:
FragmentDiffpairs fragments by id, heading path and token similarity (TokenDiff,Normaliser) and classifies them as unchanged, changed, moved, added or removed. A change is substantive aboveliving_course.diff.substantive_ratioor when a modality word flips (not,must,deprecated, per language). - Impact (
ImpactAnalyzer) maps changed fragments to the course elements that cite them through the blueprint’s citations;StalenessServicestoreselement_status(current, stale, source removed) so the studio and the API show what is out of date. - Analysis (
AnalysisService) groups impacted elements (one group per lesson or question set), estimates the cost and, below the auto threshold or after confirmation, runs one model call per group as steps of a Course Builder run of kindsync. The model sees the old fragments, the new fragments and the current element, and answers with an update that must cite fragments of the new revision (UpdateValidator; unknown or uncited output is rejected, and source text is untrusted data in the prompt). - The result is an update proposal of items (
update,citation_remap,remove,no_change,manual,uncovered).DecisionServiceaccepts, rejects, resets or regenerates items;ApplyServiceapplies the accepted ones through the blueprint applier as a new blueprint version of kindupdateand publishes it only on approval. Elements an admin edited after the last apply are detected as drift and shown as conflicts. - Learner rules (ADR 0033,
ProgressRules): completed lessons stay completed, quiz results keep their score and are compared by a max-score snapshot, a changed quiz can allow a re-attempt, and learners see an “updated since you completed it” notice (LearnerNotice). - Every decision is written to a hash-chained, append-only audit log (
AuditLog; a PostgreSQL trigger refuses updates and deletes).
Endpoints
Section titled “Endpoints”Authors and reviewers (auth:api, scope living-course:read for GET and living-course:write otherwise; policies: the session author or living_course_review may act, author or admin may view). Paths are under /api/admin/living-course:
| Method and path | Purpose |
|---|---|
GET connectors |
Connectors enabled here with configSchema, secretFields and webhooks |
GET sessions/{s}/sources |
Sources of a course with their connection and sync state |
POST sessions/{s}/sources/connect {connector, config, secrets?, schedule?} |
Validate, fetch revision 1; 201 with connection, source, webhookSecret (once); 422 when the connector refuses the settings |
PUT connections/{c} |
schedule (manual, hourly, daily, weekly), autoAnalyse, status (active, paused), config, secrets, settings.show_pending_to_learners, settings.notify_learners_of_updates |
DELETE connections/{c} |
Disconnect: paused, manual, secrets removed; revisions and audit stay |
POST connections/{c}/check |
Check now; 202, 409 for an upload or a paused source |
POST connections/{c}/webhook-secret |
Rotate; the new secret is returned once, the old one stops working |
GET sources/{s}/revisions, POST sources/{s}/revisions (multipart file) |
List, or upload a new version (no new revision when the file equals the latest) |
GET revisions/{r}, GET revisions/{r}/changes?against= |
One revision; changed, moved, added and removed fragments with old and new text |
GET sessions/{s}/staleness |
element_status of the course (works with AI off) |
GET sessions/{s}/proposals, GET proposals/{p} |
List; one proposal with items grouped by lesson |
POST proposals/{p}/analyse {confirmEstimate?} |
202 started; 409 when the estimate needs confirming; 422 when budget blocked; 503 with AI off |
POST proposals/{p}/items/{i}/accept, reject, reset |
Decide one item; 409 when it is not decidable now |
POST proposals/{p}/items/{i}/regenerate {comment} |
New suggestion for one item (one call, at most 3 per item) |
POST proposals/{p}/accept-all, reject, reanalyse |
Accept every undecided update, removal and no-change item; reject the whole proposal and acknowledge the revision; start again from the newest revision |
PUT proposals/{p}/learner-note {note} |
Plain text, up to 500 characters |
POST proposals/{p}/apply {overwrite?} |
New blueprint version of kind update; 202 with the run; 409 lists conflicts or admin edits |
GET sessions/{s}/audit |
Entries, newest first; filters action (prefix), actorType, source, from, to, page, perPage (max 200) |
GET sessions/{s}/audit/export?format=csv|json |
Download with the same filters |
GET audit/export, GET audit/verify |
Whole academy (admins only) |
GET sessions/{s}/audit/verify |
{ok, checked, brokenId, reason}; the chain covers the whole academy |
Learners (scope learner): GET /api/living-course/courses/{c}/notices (open notices of the caller: kind is topic_updated, question_reattempt, topic_retired or course_extended), POST /api/living-course/notices/{n}/dismiss (a re-attempt notice closes when the quiz is retaken), GET /api/living-course/courses/{c}/freshness (topics with an update under review, only when the course shows it; never the content). Webhooks: POST /api/living-course/webhooks/{webhookId}, below. The typed client is createLivingCourseClient in @ulams/sdk. Settings, limits and the scheduler are in Living Course settings; the commands in Build courses from the command line.
Webhook requests
Section titled “Webhook requests”The URL is https://<tenant API host>/api/living-course/webhooks/<webhookId>; the connection returns it as webhookUrl. There is no login: the credential is the signature made with the connection’s webhook secret.
| Host | Header checked |
|---|---|
| any (generic, and for tests) | X-Ulams-Signature: sha256=<hex>: HMAC-SHA256 of the raw body with the secret |
| GitHub | X-Hub-Signature-256: sha256=<hex> |
| Gitea, Forgejo | X-Gitea-Signature or X-Forgejo-Signature: hex HMAC-SHA256 |
| GitLab | X-Gitlab-Token: the secret itself |
Only push events to the tracked branch that touch the tracked paths queue a check; others answer 200 {"ignored": true}. Replies: 202 {"queued": true}; 200 for duplicate (same delivery id) or dropped (paused connection or daily check limit); 401 invalid signature; 404 unknown id; 413 body over 1 MB; 429 over 60 requests a minute per webhook. Several pushes within LIVING_COURSE_WEBHOOK_DEBOUNCE_SECONDS become one check. Bodies are never stored, only their SHA-256.
SECRET='<webhook secret>'BODY='{"ref":"refs/heads/main","after":"0123abc","commits":[{"modified":["docs/brewing.md"]}]}'SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')curl -sS -X POST "https://coffee.example/api/living-course/webhooks/<webhookId>" \ -H 'Content-Type: application/json' -H "X-Ulams-Signature: sha256=$SIG" --data-binary "$BODY"# {"success":true,"data":{"queued":true},...}Events and notifications
Section titled “Events and notifications”SourceRevisionDetected, UpdateProposalReady, UpdateProposalApplied, CourseContentUpdated and SourceCheckFailing are dispatched through Laravel events; the Notifier turns them into e-mail and in-app notifications using templates registered with templates-email. The studio stream also carries the custom events update_proposal, update_analysis and update_applied.
Writing a connector
Section titled “Writing a connector”Implement Ulams\LivingCourse\Connectors\SourceConnector, register it with SourceConnectorRegistry and list its key in LIVING_COURSE_CONNECTORS. Use Ulams\Core\Http\SafeHttp for every request. The worked example is ExampleConnector in api/packages/example-plugin; the step-by-step guide is docs/living-course/connector-plugins.md in the repository.
Scheduler
Section titled “Scheduler”living-course:poll is registered by the package on the Laravel scheduler (every 15 minutes, without overlapping). It queues checks of due Git and web connections on the builder queue and prunes webhook deliveries older than 30 days. course-builder:prune-events is not scheduled by the package; schedule it daily yourself.
Guards
Section titled “Guards”GuardsTest of the package fails the build when the code writes LMS tables directly (everything goes through domain services) or hardcodes a model name; ConnectorPluginTest and the isolation tests cover tenant boundaries of every endpoint.
Evaluating
Section titled “Evaluating”php artisan living-course:eval --fixtures=all runs three fixture sets (coffee-brewing, git-basics, injection, each in a v1 and a v2) on the fake driver. The recorded answers of a real run are replayed in CI by CassetteReplayTest; the reports are in docs/reports/phase-3-evals.