# Business rules — doh_moodle

Evidence date: 2026-10-16.

Source: `main` at `b9db23cf`.

Rules below are taken from plugin code and, where noted, from READMEs. **Inferred** means the code behaves this way and no separate requirements document states it. Do not treat a README sentence as the rule when the code disagrees; those clashes are listed at the end.

Upstream Moodle rules (password policy, guest access, grade aggregation) are not restated.

## Identity and access

### BR-001 — The plugin does not create Moodle users

**Inferred** from `sync_cohort_members.php`. A Gibbon username with no Moodle user is counted as not found and is not inserted into `user`. The README says users should already exist, synced by `auth_db`. Whether that auth plugin is enabled is configuration outside this code.

### BR-002 — Cohort removal follows External database authentication

**Implemented.**

A user listed in the member view is added to the cohort.

A user absent from the view is removed from the Moodle cohort only when all of the following are true:

1. `auth_db` is enabled.
2. `auth_db` "Remove user" is full delete.
3. The Moodle user is not suspended.

If auth_db is disabled, or remove-user is unset, keep internal, or suspend internal, absence from Gibbon does not remove the cohort membership.

Suspended users are never removed by this sync.

### BR-003 — Students are blocked before the course start; enrolment stays

**Implemented** in `local_course_startdate`.

- The rule runs only when the plugin `enabled` setting is on. The default is on.
- The site course is exempt.
- Site administrators are exempt.
- A user is exempt in a course when they have any of: `moodle/course:update`, `moodle/course:manageactivities`, `moodle/course:viewhiddencourses`.
- Empty `startdate` means the course has started.
- With grace on (default), the gate is Moodle's classified start, which applies the site grace period for future courses.
- With grace off, the gate is the raw `course.startdate` timestamp.
- A blocked student is redirected to My courses. Enrolment rows are not changed.

### BR-004 — My credits is the logged-in user's own rows

**Implemented.** Guests are not offered the link. The page has no userid parameter. Another user's credits are not requested by this page.

### BR-005 — Web services require a configured API key

**Implemented.** An empty stored key fails every Gibbon sync external function, including when the caller sends an empty key. The comparison is a plain string match (`!==`), not a hash.

`get_students` and `get_grades` do not call `require_capability` inside the method. **Inferred:** anyone who holds a token for the Gibbon Sync Service and the API key can read the rows those queries return. `get_courses` also requires `moodle/course:view` on each course for the token user.

### BR-006 — Manual course roles on cohort-enrolled users are replaced

**Implemented** in `assign_user_roles`.

For a user enrolled in the course through the cohort instance being synced:

- The target role is the mapped Gibbon role, or the default enrol role when the Gibbon role is missing or unmapped.
- Role rows for this enrolment instance (`enrol_cohort` plus that enrol id) that are not the target role are unassigned.
- Role rows with an empty component (assigned manually at the course context) are unassigned.
- Role rows with any other component are kept.

This is stronger than "sync adds a role". A manual course role on that user is removed on the next enrolment sync.

### BR-007 — Capability `local/gibbon_cohort:manage` is granted to the manager archetype

**Implemented** as a capability definition. No plugin page inspected requires it. **Inferred:** it does not currently gate My credits or the sync task. Sync is cron. Settings are site admin (`$hassiteconfig`).

## Classes, courses, and groups

### BR-008 — A Moodle cohort represents a Gibbon class

**Documented** by the example SQL in the README (`gibbonCourseClass.gibbonCourseClassID` cast to char becomes `idnumber`). **Inferred** as the intended meaning of `idnumber`. The code matches whatever string the configured id field contains. It does not check that the value is a Gibbon class id.

Cohorts this plugin creates or updates are stamped `component = local_gibbon_cohort`. Cleanup of cohorts missing from Gibbon is limited to that component. Cohorts created by hand or by another plugin are not deleted by this cleanup. **Implemented.**

### BR-009 — Course link is `idnumber`, not the Moodle course id

**Implemented.** `moodleCohortCourse.gibbonCourseID` (field name configurable) must equal `course.idnumber`. A missing course is counted as not found. The plugin does not create the Moodle course in the enrolment step.

Step 4 (group mode) can also match `shortname` when `idnumber` does not hit, using the enrol_database new-course field names. **Implemented** in `course_group_mode_sync.php`.

### BR-010 — Separate groups are forced for courses the group-mode step resolves

**Implemented.** `groupmode` becomes separate groups and `groupmodeforce` becomes 1. Each course module whose group mode is not already separate groups is set to separate groups.

The step does nothing when `enrol_database` `newcoursetable`, new-course shortname, or new-course fullname is empty.

"Create groups" (a cohort group inside the course) is a different switch. It defaults to off. **Implemented.**

### BR-011 — Only the current Gibbon school year is in the example views

**Documented**, not enforced by Moodle. The README's example `CREATE VIEW` statements filter `gibbonSchoolYear.status = 'Current'` and exclude class roles `LIKE '% - Left'`, and they require `gibbonPerson.status = 'Full'`. If the live views differ, Moodle syncs whatever those views return. The view SQL is not in this repository.

## Credits

### BR-012 — Credits are awarded from the course total only

**Implemented.**

The observer returns unless the grade item type is `course`. Activity grades do not change `local_gibbon_cohort_user_credits` by themselves.

The cohort used for the award is the minimum `enrol.customint1` among the user's cohort enrolments on that course. **Implemented.** If the user has two cohort enrolments, the lower Moodle cohort id supplies the pass mark and the credit values. That choice is **Inferred** as an implementation detail (the SQL uses `MIN`), not a documented business preference.

### BR-013 — Pass mark is inclusive

**Implemented.** Credits are stored when `finalgrade` is not null and `(float) finalgrade >= (float) pass_mark`. Otherwise theoretical credits, clinical credits, and `is_core` are stored as 0.

A missing credits row for the cohort also yields zeros.

A null final grade yields zeros. The row is still written.

### BR-014 — Changing the class pass mark or credit values recalculates existing learners

**Implemented.** `recalc_user_credits_for_cohort` runs when the synced theoretical credits, clinical credits, pass mark, or core flag differ from the stored cohort row (and on insert). It recomputes every user enrolled via that cohort on each of that cohort's courses, using the course-total grade.

### BR-015 — Year-group minimums are stored and not enforced on My credits

**Implemented** as sync into `local_gibbon_cohort_yeargroup` (`idnumber`, `name`, `minimum_theoretical_credits`, `minimum_clinical_credits`). Rows absent from the view are deleted.

**Inferred:** nothing in `my_credits.php` compares a user's totals with those minimums. The README says the course-year-group table supports "has user met year group minimum?". That check was not found as a page or as a function name in the plugin PHP that was searched.

### BR-016 — One credit row per user per course

**Implemented** as a unique index on `(userid, courseid)` in `install.xml`.

### BR-017 — Stale per-cohort credit rows are not deleted by the sync that was read

**Inferred.** `credits_sync.php` deletes missing year groups and missing course-year-group links. It updates or inserts `local_gibbon_cohort_credits`. No `delete_records` on that table was found. A class removed from the credits view can leave its old pass mark in Moodle until something else removes the row.

## Grades exposed to Gibbon

### BR-018 — Student export skips suspended and deleted users and inactive enrolments

**Implemented** in `get_students`. Conditions: `user.deleted = 0`, `user.suspended = 0`, `user_enrolments.status = 0`, `course.id > 1`. Optional `last_sync` filters `user.timemodified`, not enrolment time.

Without `course_id` the rows are `SELECT DISTINCT` users. Course membership is not in the payload. **Implemented.**

### BR-019 — Grade export is final grades on modules and the course total

**Implemented.** `grade_items.itemtype IN ('mod', 'course')` and `finalgrade IS NOT NULL`. Deleted users are excluded. Suspended users are not excluded. `passmark` in the payload is the grade item's `gradepass`, which is not the same field as the cohort pass mark used to award credits.

Credit columns on the grade payload are copied from `local_gibbon_cohort_user_credits` for that user and course, or 0 when the table is missing or the columns are absent. They are attached to every grade row in the course, including activity rows. **Implemented.**

### BR-020 — Gibbon cannot set a Moodle grade through this service

**Implemented.** `sync_grades` writes `feedback` and `timemodified` on an existing `grade_grades` id. Unknown grade ids are returned in `errors`. The numeric grade is untouched.

The README's "bidirectional grade sync" sentence is not this rule.

## Plagiarism

### BR-021 — A check runs only when the plugin and the activity are both on

**Implemented** for assignment events. Global `enabled` must be true and the course-module setting `use` must be 1. Updates call the same path as creates.

### BR-022 — The threshold and the student-report flag are per assignment

**Implemented** as form fields on `assign` only. Threshold options are 10 through 100 step 10. Student report defaults to shown. How the threshold flags a result inside `process_api_response` was not line-traced. Treat the options as the configured inputs, not as a confirmed flagging rule, until that function is read for a change.

### BR-023 — Quiz attempts have an observer and no settings control in the form that was read

**Implemented** observer. **Requires confirmation** that a quiz can ever have `use = 1`, because `coursemodule_standard_elements` returns before adding fields when the module is not `assign`.

## Deployment and configuration rules

### BR-024 — The Azure pipeline that is committed deploys the dev environment

**Implemented** in YAML: variable group `deploy-secrets-dev`, pool `selfhosted-dev`, environment `moodle-dev`, SSH endpoint `ssh-moodle`, artifact name `moodle`, keep 5 releases via `KEEP=5` passed to `/home/deploy/release.sh`.

Triggers: branches `main` and `releases/*`, and pull requests targeting `main`.

QA and production are comments. **Not implemented** as selected values.

The build runs `composer install --no-dev` and then runs `vendor/bin/phpunit` only if that binary exists. PHPUnit is a dev dependency, so this pipeline's test step does not install PHPUnit. **Inferred** from `composer.json` `require-dev` and the pipeline script.

### BR-025 — Committed `configx.php` is a local config, not a production rule

**Implemented** as a file. wwwroot `http://localhost/moodle_stable`, database type `mariadb`, database name `moodle_stable`, prefix `mdl_`, dataroot `C:\moodledata`. A database username and password are in the file and are intentionally omitted here.

`config.php` is not committed. Moodle will not boot from `configx.php` unless a server is arranged to load it. **Inferred.**

## Conflicts inside the repository

| Topic | README or other doc | Code |
|---|---|---|
| Schedule | Three tasks, hourly and offset by 5 and 10 minutes; CLI examples use those classes | One task, `*/5`, class `sync_all` |
| Member removal | One sentence says users absent from the external database are removed. A later section says only on auth_db full delete | Full-delete section matches the code |
| Grade sync | "Bidirectional grade sync" | Feedback field only |
| `TESTING.md` | Linked from the cohort README | File not in the plugin |
| Webhook / Gibbon DB on `local_gibbon_sync` | Settings and "future webhook" string | No reader |
| `api_key` in Silicon Ark `doh_sims` | Described there as optional | Required here |
| Pipeline header | Says `dob_doh_moodle` | Repository is `doh_moodle` |
| `.gitignore` vs `NewKnowledgeBase.md` | Knowledge note says ignore `config.php` | Config section of `.gitignore` is empty; `configx.php` is tracked |

## Unknown / requires confirmation

- The live "Remove user" setting, and whether auth_db points at Gibbon.
- Whether course `idnumber` values in the live site equal Gibbon course ids.
- Which Gibbon client runs (LMS Sync in `doh_sims`, or `gibbon_module/MoodleSync` from this tree).
- Whether year-group minimums are enforced anywhere outside this repository.
- Whether root `check_*.php` scripts are part of operations or accidental commits.
- Production institution name and URL.
