# local_extenddeadline

Learner self-service deadline extensions for assignments and quizzes, requested from the VLearn React portal.

- **Component:** `local_extenddeadline`
- **Moodle:** 4.1.3 + IOMAD · **PHP:** 7.4+ (verified on 7.4.33 and 8.3.31)
- **Depends on:** `local_learningpaths` (declared in `version.php`)
- **Spec:** `docs/superpowers/specs/2026-08-25-activity-extension-design.md`
- **Plan:** `docs/superpowers/plans/2026-08-25-activity-extension.md`

---

## 1. What it does

A learner who has missed — or is about to miss — an assignment or quiz deadline requests more time from the React portal and receives it immediately. There is **no approval step, no ticket, and no pending state**. A request either satisfies every rule and is granted synchronously, or it is refused with a specific machine-readable reason.

The deadline is moved by writing a **per-user override row** (`assign_overrides` / `quiz_overrides`) — the same mechanism Moodle's own Overrides UI uses — so nothing downstream needs to know this plugin exists.

### Business rules

| Rule | Value |
|---|---|
| Supported modules | `assign`, `quiz` |
| Duration buckets | 1, 3, 5, 7, 10 days |
| New deadline | `time() + (days × 86400)` — from the moment of application |
| Request window opens | when the activity opens (`allowsubmissionsfromdate` / `timeopen`) |
| Request window closes | effective deadline **+ 72 hours** |
| Per activity | one extension, once, ever |
| Per program | 3 grants total across all activities in the learning path |
| Ceiling | never past the program or course end date |
| Reason | Work · Travel · Medical · Personal · Other (free text required) |
| Approval | always automatic |

### Why the deadline is measured from grant time

The feature exists for learners who have **already missed** the deadline. Measuring from the original deadline would hand someone applying five days late only two usable days, while still consuming one of their three grants for the program. Measuring from the moment of application always delivers the full duration requested.

### Explicitly out of scope

- Ticketing / helpdesk integration of any kind
- Custom durations or dates beyond the five buckets
- Any Moodle-side learner UI — the portal is the only surface
- A bespoke PM report screen (see §9)
- **Granting additional quiz attempts.** An extension gives time, never attempts.

---

## 2. Architecture

```
React portal
     │  (2 web service calls, REST + token)
     ▼
classes/external/…            thin: validate params, shape JSON
     │
     ▼
classes/local/grant_service   orchestration: lock, transaction, ledger
     │
     ├─► classes/local/eligibility        pure rule engine  (no DB, no globals)
     ├─► classes/local/activity_state     reads deadline / opens / benefit
     ├─► classes/local/program_context    reads program, ceiling, quota
     └─► classes/local/writer/…           the ONLY code that writes overrides
                │
                ▼
        assign_overrides / quiz_overrides   +  local_extenddeadline_grant
```

### File layout

```
local/extenddeadline/
  version.php                            metadata + local_learningpaths dependency
  db/install.xml                         local_extenddeadline_grant
  db/access.php                          local/extenddeadline:request
  db/services.php                        two external functions
  lang/en/local_extenddeadline.php       29 strings
  classes/local/
    eligibility.php                      every business rule, pure
    eligibility_result.php               value object, private constructor
    activity_state.php                   per-module deadline / open / benefit reader
    program_context.php                  membership, ceiling, quota, existing grant
    grant_service.php                    lock + transaction orchestration
    writer/writer.php                    abstract base + factory
    writer/assign_writer.php             assign_overrides
    writer/quiz_writer.php               quiz_overrides
  classes/external/
    get_options.php                      read function
    request_extension.php                write function
  classes/event/extension_granted.php
  classes/privacy/provider.php
  cli/seed_e2e_fixture.php               end-to-end test fixture
  cli/e2e_curl_test.sh                   19 HTTP assertions
  reports/extension_grants.sql           Configurable Reports definition
  tests/                                 8 files, 70 tests
```

### The boundary that matters

`eligibility` takes plain scalars and returns a plain object. **No `$DB`, no `$USER`, no `global`, no `time()`** — every input is injected. This is why the entire rule matrix is unit-testable with zero database fixtures, and its test class extends `\basic_testcase` (not `\advanced_testcase`) specifically to enforce it. If a future change needs a global in that class, the design has drifted.

The **writers are the only code permitted to touch the override tables.** This is a deliberate constraint, not a convention — see §7.

---

## 3. Data model

### `local_extenddeadline_grant`

The ledger. One row per granted extension. Append-only — nothing updates a row after insert.

| Column | Type | Null | Notes |
|---|---|---|---|
| `id` | int(10) | no | PK, sequence |
| `userid` | int(10) | no | → `user.id` |
| `programid` | int(10) | no | → `learningpaths.id` |
| `courseid` | int(10) | no | → `course.id` |
| `cmid` | int(10) | no | → `course_modules.id` |
| `modname` | char(20) | no | `assign` or `quiz` |
| `instanceid` | int(10) | no | `assign.id` or `quiz.id` |
| `reasonbucket` | char(20) | no | `work\|travel\|medical\|personal\|other` |
| `reasontext` | text | **yes** | required only when bucket is `other` |
| `daysgranted` | int(3) | no | 1, 3, 5, 7 or 10 |
| `originaldeadline` | int(10) | no | snapshot, before the grant |
| `granteddeadline` | int(10) | no | snapshot, after the grant |
| `overrideid` | int(10) | no | the `assign_overrides` / `quiz_overrides` row |
| `idempotencykey` | char(64) | no | client-generated, unique per intent |
| `timecreated` | int(10) | no | |

**Keys and indexes**

| Name | Type | Columns | Purpose |
|---|---|---|---|
| `primary` | primary | `id` | |
| `useractivity` | **unique** | `userid, cmid` | enforces one extension per activity, ever |
| `idempotencykey` | **unique** | `idempotencykey` | makes retries and double-clicks idempotent |
| `userprogram` | index | `userid, programid` | the 3-per-program quota count |
| `cmid` | foreign | `cmid` → `course_modules.id` | indexes the privacy provider's `WHERE cmid = ?` lookups |

There is deliberately **no `status` column** — with no pending state, a row existing *is* the grant.

Both deadlines are stored as **snapshots rather than derived**, because a PM can later edit or delete the override by hand. `overrideid` records the link, but nothing depends on it for correctness: the ledger stays truthful about what was granted even if the override is gone.

The table is named for the component with a `_grant` suffix (26 chars). Moodle caps table names at 28 (`lib/xmldb/xmldb_table.php`, `NAME_MAX_LENGTH`).

### Foreign schema (read-only)

`learningpaths`, `learningpath_users` and `learningpath_courses` belong to `local_learningpaths` and are defined **programmatically in its `db/install.php`**, not an `install.xml`.

```
learningpaths        id, name, description, startdate, enddate, deleted, companyid, …
learningpath_users   id, learningpathid, userid, enrollment_date
learningpath_courses id, learningpathid, courseid, required, position
```

Three consequences this plugin codes around:
1. `enddate` defaults to `0` — that means **no cap**, not the epoch.
2. There is a `deleted` flag; every query filters `deleted = 0`.
3. None of these tables have keys beyond the primary, so `learningpath_users.userid` and `learningpath_courses.courseid` lookups are unindexed. This plugin does not add indexes to another plugin's tables; if the program lookup shows up under load, raise it with the owners of `local_learningpaths`.

---

## 4. Web service API

Both functions are registered against the existing `local_mobile` and `MOODLE_OFFICIAL_MOBILE_SERVICE` services, so the portal's token setup is unchanged — only the function names are new.

### 4.1 `local_extenddeadline_get_options`

**Type:** `read` · **Capability:** `local/extenddeadline:request` · **AJAX:** yes

Called when the portal renders the activity list or opens the request modal. Answers "can this learner request here, and for how long?"

#### Parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `cmids` | array of int | yes | Course module ids to report on |
| `programid` | int | yes | Learning path id |

Batched deliberately: the activities screen shows many cards, and per-activity calls would be N round trips to decorate one list.

#### Returns

```
{
  "activities": [
    {
      "cmid":             int,
      "eligible":         bool,
      "reason":           string,   // machine code; "" when eligible
      "reasondisplay":    string,   // localised; "" when eligible
      "deadline":         int,      // effective last-submission timestamp
      "windowcloses":     int,      // deadline + 72h
      "alloweddurations": [int],    // subset of [1,3,5,7,10]
      "grantsused":       int,
      "grantslimit":      int,      // always 3
      "existinggrant": {            // OMITTED when there is none
        "daysgranted":     int,
        "granteddeadline": int,
        "timecreated":     int
      }
    }
  ],
  "reasonbuckets": [
    { "value": string, "label": string }
  ]
}
```

#### Two contract details the portal must handle

**`existinggrant` is absent, not `null`.** Moodle 4.1's `external_single_structure` has no `NULL_ALLOWED` option, and `clean_returnvalue()` rejects a present `null` for a structure key — `VALUE_OPTIONAL` only works by omitting the key entirely. **Test for key presence, never for null.**

**Render only `alloweddurations`.** Do not hardcode `[1,3,5,7,10]`. The server trims that set against the program and course end dates; a hardcoded list means a learner picks an option that is then rejected.

`reasonbuckets` is returned so the portal never hardcodes the reason list either.

### 4.2 `local_extenddeadline_request_extension`

**Type:** `write` · **Capability:** `local/extenddeadline:request` · **AJAX:** yes

#### Parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `cmid` | int | yes | Course module id |
| `programid` | int | yes | Learning path id |
| `days` | int | yes | Must be a member of that activity's `alloweddurations` |
| `reasonbucket` | string | yes | `work`, `travel`, `medical`, `personal`, `other` |
| `reasontext` | string | no (default `''`) | **Required when `reasonbucket` is `other`** |
| `idempotencykey` | string | yes | Client-generated, ≤ 64 chars, unique per intent |

#### Returns

```
{
  "grantid":         int,   // ledger row id
  "daysgranted":     int,
  "granteddeadline": int,   // the new deadline
  "grantsused":      int,
  "grantsremaining": int
}
```

#### `get_options` is not authorisation

`request_extension` **re-runs every check `get_options` performed.** The two calls are separated in time: between them the learner can exhaust their quota in another tab, the window can close, or a PM can change the activity. Treating call 1 as permission for call 2 is a TOCTOU hole. Call 1 renders a UI; call 2 is the only authority. This is covered by a dedicated regression test.

---

## 5. Error handling

Rule refusals are returned as **exceptions**, not `success: false`. Moodle's web service layer returns `{exception, errorcode, message}`, which the portal can switch on directly, and it keeps "you can't do this" cleanly distinct from "it worked".

The `errorcode` vocabulary is **the same set** `get_options` returns as `reason`, so the frontend maps one dictionary to both calls.

| Code | Meaning | Surfaced by |
|---|---|---|
| `already_extended` | Already used an extension on this activity | `get_options` |
| `quota_exhausted` | All 3 program grants used | both |
| `window_closed` | Before the activity opened, or past deadline + 72h | both |
| `no_attempts_left` | Quiz: no attempts remain, so time cannot help | both |
| `already_submitted` | Assign: already submitted, so time cannot help | both |
| `program_ended` | Every duration would run past the program/course end | both |
| `not_in_program` | Learner or course not in the given program | both |
| `unsupported_activity` | Module type is not `assign` or `quiz` | both |
| `invalidduration` | `days` not in this activity's allowed set | `request_extension` |
| `invalidreasonbucket` | Unknown reason bucket | `request_extension` |
| `reasontextrequired` | Bucket is `other` with empty text | `request_extension` |
| `invalididempotencykey` | Key empty or longer than 64 chars | `request_extension` |
| `extensionbusy` | **Transient** — lock not acquired within 10s | `request_extension` |

`extensionbusy` is the only code the client must treat differently: show "try again", not a permanent refusal.

### Three cases that are deliberately *not* errors

| Case | Response |
|---|---|
| Repeat request on an already-extended activity | **Success**, carrying the existing grant |
| Same `idempotencykey` replayed | **Success**, carrying the original grant |
| `(userid, cmid)` unique violation from a race | **Success**, carrying the winner's grant |

Same-activity double submission is intentionally indistinguishable from a retry, so the portal needs no special-casing.

---

## 6. Scenarios handled

### Eligibility decision order

Refusals are evaluated in a **fixed order** so the reported reason is deterministic when several rules would refuse at once:

```
1. hasexistinggrant        → already_extended
2. grantsused >= 3         → quota_exhausted
3. outside request window  → window_closed
4. cannot-benefit reason   → no_attempts_left | already_submitted
5. no duration fits ceiling→ program_ended
6. otherwise               → eligible, with the fitting durations
```

All ten pairwise orderings that a caller can reach are covered by tests.

### Per-module semantics

| | assign | quiz |
|---|---|---|
| Effective deadline | `cutoffdate` if set, else `duedate` | `timeclose` |
| Opens at | `allowsubmissionsfromdate` | `timeopen` |
| Existing overrides applied by | `update_effective_access($userid)` | `quiz_update_effective_access($quiz, $userid)` |
| Written on grant | `duedate` **and** `cutoffdate` | `timeclose` only |
| Cannot benefit when | already submitted | no attempts remain |

**Why assign writes both dates:** `cutoffdate` is the hard gate in `assign::submissions_open()`. Moving `duedate` alone would leave a closed assignment closed and the learner still unable to submit. When the activity has `cutoffdate = 0` it never closes, so only `duedate` moves — that clears the late flag, which is all that remains to give.

**Why quiz never touches `attempts`:** an extension grants time, not attempts. A learner whose attempt was auto-submitted at `timeclose` gains nothing from a date change, so they are filtered out *before* the option is offered (`no_attempts_left`) rather than being handed a fresh attempt. Granting an attempt would be a re-attempt — unfair to learners who submitted on time, and gameable across three requests per program.

### Ceiling behaviour

```
ceiling = min(nonzero(learningpaths.enddate, course.enddate))
```

A duration is offered only when `now + (N × 86400) <= ceiling`. `ceiling = 0` means no cap.

A **negative** ceiling cannot arise from the current callers (both end dates are unsigned, `0` when unset) but is handled explicitly: it **fails closed**, refusing every duration rather than granting them all. Failing open on an unexpected value would silently unlock the whole feature.

### Edge cases with dedicated tests

- Request exactly at deadline + 72h → **open**; one second later → closed
- Request exactly at the moment the activity opens → open
- A duration landing exactly on the ceiling → **allowed**
- 2 grants used → eligible; 3 → refused
- Draft submission on an assign → does **not** block (a draft is not a submission)
- Quiz with `attempts = 0` (unlimited) → never blocked
- Existing override already present → read and respected, not overwritten blindly
- Program soft-deleted (`deleted = 1`) → `not_in_program`
- Course in a different program than the one passed → `not_in_program`

---

## 7. Safety and integrity

### Concurrency: three distinct races, three distinct guards

**Same activity, two tabs.** Solved structurally by `UNIQUE (userid, cmid)`. Both requests attempt the insert; one wins; the loser catches `dml_write_exception` and returns the winner's grant as a success. An application-level `record_exists()` check would race by construction — the constraint cannot.

**Different activities, two tabs — the dangerous one.** Both read `grantsused = 2`, both pass "under 3", both insert, learner reaches 4. A unique index cannot catch this because the rows are legitimately different. The quota is therefore re-counted **inside a lock**:

```php
$lock = lock_config::get_lock_factory('local_extenddeadline')
          ->get_lock("quota_{$userid}_{$programid}", 10);
$transaction = $DB->start_delegated_transaction();
    // re-count here, not before the lock
    // write override, insert ledger row
$transaction->allow_commit();
$lock->release();
```

Moodle's lock factory is DB-backed, so it holds across web nodes. A `static` or MUC-based guard would not.

**Double-click / network retry.** The portal generates a UUID when the request form opens (not per click) and sends it as `idempotencykey`. `UNIQUE (idempotencykey)` means a retry returns the original grant. Distinct from the tab case: same key means same intent.

### Transaction correctness

`moodle_transaction::rollback()` **always re-throws** what it is given (`lib/dml/moodle_transaction.php`). The rollback call is therefore wrapped in its own try/catch, and the original exception is handled afterwards. Skipping the rollback entirely would leave the transaction open and Moodle would fail with `dml_transaction_exception` instead of returning the existing grant.

Everything touching the DB — the override row, the `{event}` calendar rows, the ledger insert — is inside one transaction and rolls back together. Two things sit outside that guarantee, both failing in the safe direction:

- The **MUC cache delete** is not transactional. Deleting a key for data that then rolls back is harmless; the next read repopulates from the DB. Over-deleting is safe, under-deleting is not.
- **Event dispatch is half-buffered** — `lib/classes/event/manager.php` defers external observers until commit but runs internal ones immediately. Nothing in this codebase observes `user_override_created`, so there is no side effect to strand.

### The five-step override write

Every override write does **all five** steps. Skipping any of them is a real bug that already exists elsewhere in this codebase (`local/iomad/lib/user.php` and `local/report_user_license_allocations/lib.php` raw-delete override rows, leaving orphaned calendar events and a stale MUC entry that then feeds `mod_quiz_cm_info_dynamic()`).

| # | assign | quiz |
|---|---|---|
| 1 | insert `assign_overrides` | insert `quiz_overrides` |
| 2 | delete MUC key `{id}_u_{userid}` | delete MUC key `{id}_u_{userid}` |
| 3 | `assign_update_events()` | `quiz_update_events()` |
| 4 | — | `quiz_update_open_attempts()` |
| 5 | fire `user_override_created` | fire `user_override_created` |

Confining this to two writer classes is how the plugin avoids repeating that mistake. Moodle has **no public API for creating an override** — `overrideedit.php` does it inline — so the sequence is replicated deliberately.

### Data integrity guarantees

| Guarantee | Enforced by |
|---|---|
| One extension per activity per learner | DB unique constraint, not application logic |
| At most 3 grants per program | lock + in-transaction re-count |
| Retries never double-grant | DB unique constraint on the idempotency key |
| Ledger stays truthful if an override is later edited | date snapshots, not derived values |
| A partial failure leaves no orphan override | single transaction with explicit rollback |

---

## 8. Security

| Control | Implementation |
|---|---|
| Authentication | Moodle web service token; `local_mobile` / official mobile service |
| Authorisation | `require_capability('local/extenddeadline:request', $modulecontext)` |
| Context validation | `self::validate_context($context)` before any capability check |
| Capability definition | `CONTEXT_MODULE`, `captype` write, `student` archetype `CAP_ALLOW` |
| Input validation | `self::validate_parameters()` against declared `PARAM_*` types |
| Output validation | `execute_returns()` structures, enforced by `clean_returnvalue()` |
| SQL injection | `$DB` API with bound parameters throughout; no string-concatenated SQL |
| Acting on another user | Impossible — the userid is always taken from `$USER`, never from a parameter |
| Idempotency key abuse | Rejected if empty or > 64 chars, **before** it is used as a lookup probe |
| Free-text reason | `PARAM_TEXT`, stored only, never rendered as HTML by this plugin |
| Privacy / GDPR | Full provider: metadata, export, and both delete paths |

### Two specific hardening decisions

**The learner id is never a parameter.** Both functions derive it from `$USER`, so no crafted request can grant an extension to somebody else. `programid` and `cmid` *are* parameters, but both are validated: the learner must be a member of that program, and the course must belong to it, or the request is refused with `not_in_program`.

**Idempotency key length is validated before use.** The column is `char(64)`. An over-long key would be truncated by the database, letting two distinct keys collide on their first 64 characters and each silently receive the other's grant — defeating the entire idempotency guarantee. The key is rejected rather than truncated, and the check runs *before* the replay lookup so an over-long key is never used as a probe.

**No `riskbitmask` is declared**, deliberately. No core `RISK_*` constant models "self-service assessment advantage", and forcing an ill-fitting one would mislead the Define Roles UI. Core's nearest analogues (`mod/assign:grantextension`, `mod/quiz:manageoverrides`) declare none either. The quota, window and eligibility rules are the actual control.

---

## 9. Reporting and visibility

There is **no custom admin screen**. Extensions already appear on each activity's Overrides page, and rebuilding that view would be waste.

Three things live only in the ledger and render nowhere in Moodle: the reason bucket and text, the learner's quota state ("why can't I request?"), and provenance (an override row looks identical whether this plugin or a PM wrote it).

Those are covered by `reports/extension_grants.sql`, a pre-built SQL report for `blocks/configurable_reports` (already installed). PMs get filtering, pagination, scheduling and CSV export without this plugin shipping a page, renderer, capability or template.

Setup: *Site administration → Reports → Configurable reports → Add report → SQL report*, then paste the file's contents. Leave `prefix_` as written — the block substitutes the real table prefix.

An `extension_granted` event also fires on every grant, carrying `daysgranted`, `granteddeadline`, `programid` and `reasonbucket`, for anyone who wants to observe it.

---

## 10. Testing

**70 PHPUnit tests, 0 failures, 0 errors** — verified on both PHP 7.4.33 and 8.3.31.

| Suite | Tests | Covers |
|---|---|---|
| `eligibility_test` | 21 | the full rule matrix — pure unit, no fixtures |
| `activity_state_test` | 9 | per-module deadline, opens, benefit, override application |
| `program_context_test` | 7 | membership, ceiling, quota, soft-delete |
| `assign_writer_test` | 6 | all five write steps + `submissions_open()` flips |
| `quiz_writer_test` | 5 | all five write steps + attempts left untouched |
| `grant_service_test` | 9 | locking, idempotency, quota, refusal error codes |
| `external_test` | 7 | params, capability on both functions, TOCTOU guard |
| `privacy_provider_test` | 6 | export and both delete paths |

Run one suite:

```
/opt/homebrew/opt/php@7.4/bin/php vendor/bin/phpunit local/extenddeadline/tests/eligibility_test.php
```

### The assertions that actually matter

Writing an override row proves nothing about whether a learner can submit. Two tests assert the real outcome:

- `assign_writer_test::test_the_learner_can_actually_submit_again` — `assign::submissions_open()` flips **false → true**
- `external_test::test_request_refuses_when_quota_exhausted_after_get_options_said_yes` — the TOCTOU guard

### End-to-end HTTP verification

PHPUnit calls `execute()` directly and never exercises the REST layer, token auth, service registration or JSON serialisation. `cli/e2e_curl_test.sh` covers those with **19 assertions** against `/webservice/rest/server.php`:

```
eval "$(php local/extenddeadline/cli/seed_e2e_fixture.php | grep '^export')"
bash local/extenddeadline/cli/e2e_curl_test.sh
php local/extenddeadline/cli/seed_e2e_fixture.php --cleanup
```

It covers eligibility, granting, idempotent replay, the same-activity repeat, `already_extended` on re-query, an unknown program, and an invalid token. The fixture creates a course, learner, program and a past-cutoff assignment, and `--cleanup` removes all of it.

### Known environment quirks

Three pre-existing defects in this codebase affect tests; all are worked around inside our own test files, none are fixed here.

1. **`block_content_approval`** builds a renderer inside its `course_module_created` observer, which the activity generator forbids. Every test that creates an activity calls `$this->setAdminUser()` first — the observer returns early for site admins.
2. **`local_placement`'s `user_created` observer** dereferences the `rollnumber` / `accesstype` custom profile fields without checking they exist, fataling on any site lacking them. Tests call `\core\event\manager::phpunit_replace_observers([])` in `setUp()`; it auto-restores per test. Note an event sink does **not** suppress observers.
3. **`mod/assign` requires `assign_tag`** — `mod/assign/locallib.php` dereferences it unconditionally and the stock generator has no default. Tests pass `'assign_tag' => Assignment_Assign` to every `create_module('assign', …)`.

On PHP 8.3, unrelated dynamic-property deprecations from core and IOMAD trip `beStrictAboutOutputDuringTests`, so every suite reports `Risky`. On 7.4 the suite is clean.

---

## 11. Frontend integration checklist

- [ ] Call `local_extenddeadline_get_options` with **all visible cmids at once**, not per card
- [ ] Pass `programid` on **both** calls — a course can belong to several learning paths and a learner to several programs; without it the server cannot tell which end date and which quota apply
- [ ] Generate `idempotencykey` (UUID) **when the form opens**, not per click
- [ ] Render only `alloweddurations`; never hardcode `[1,3,5,7,10]`
- [ ] Test `existinggrant` for **key presence**, not for `null`
- [ ] Build the reason picker from `reasonbuckets`
- [ ] Require `reasontext` when the learner picks `other`
- [ ] Map `errorcode` and `reason` through **one** dictionary — they share a vocabulary
- [ ] Treat `extensionbusy` as transient ("try again"); everything else is a permanent refusal
- [ ] A repeat request on an extended activity returns **success** with the original grant — no special-casing needed
- [ ] Disable the submit button on click as UX, but rely on the server guards for correctness

---

## 12. Installation

1. Ensure `local_learningpaths` is installed — it is a declared dependency.
2. Place the plugin at `local/extenddeadline/`.
3. Run `php admin/cli/upgrade.php --non-interactive` (or visit Site administration → Notifications).
4. Confirm the table and both functions registered:

```sql
SELECT name FROM mdl_external_functions WHERE name LIKE 'local_extenddeadline%';
```

5. Ensure web services and the REST protocol are enabled, and that learners' role has `webservice/rest:use`.

The `student` archetype receives `local/extenddeadline:request` by default at module context.
