Imported from Annoto/moodle-local_annoto (
AGENTS.md). Install upstream withnpx skills add Annoto/moodle-local_annoto. Copyright stays with the author.
Coding-agent rules for moodle-local_annoto
Rules live here; every CLAUDE.md is a one-line @AGENTS.md import. Edit this file, never the
import. A rule that applies to one folder goes in a nested AGENTS.md there, with its own
one-line CLAUDE.md beside it.
What this repo is
local_annoto, the Moodle local plugin that injects the Annoto widget into videos on Moodle course pages. It is published in the Moodle Plugins directory (moodle.org/plugins/local_annoto) and supports Moodle 3.11 up to 5.2 from a single release. It is for Moodle site administrators at institutions with an Annoto subscription, who install and configure it; teachers and students only see the widget it injects.
The plugin is thin: it injects page scripts and serves parameters (SSO token, course context, settings) to the AnnotoMoodle CDN bundle, which lives in a separate repo (Annoto/moodle-local-js) and is loaded from the local_annoto/moodlejsurl setting. Most player and widget behavior is in that bundle, not here.
Layout
| Package / folder | Role | Depends on |
|---|---|---|
version.php |
Plugin version, release, supported Moodle range, component name | — |
lib.php |
Legacy Moodle callbacks (pre-4.4 page hooks, course-module form, navigation), page targeting, SSO token, get_jsparams data |
Moodle core |
externallib.php |
External (web service / AJAX) functions get_jsparams and set_completion |
lib.php, classes/ |
classes/ |
Autoloaded classes: Hooks API callbacks (4.4+), completion persistents, event observer, GA4 telemetry, logger, privacy provider, scheduled tasks | Moodle core |
db/ |
Moodle plugin metadata: install.xml schema, upgrade.php, services.php, hooks.php, events.php, tasks.php, access.php |
— |
amd/src/ |
AMD module local_annoto/annoto: calls get_jsparams, sets window.moodleAnnoto.params, loads the CDN bundle |
Moodle core AMD modules, the CDN bundle |
amd/build/ |
Minified output of amd/src/, generated by Moodle's grunt |
amd/src/ |
initkaltura.js |
Plain ES5 page script (no build) that captures Kaltura V7 (playkit) players for the bundle | window.KalturaPlayer, the CDN bundle |
settings.php |
Admin settings page (local_annoto/*) |
classes/admin_setting_custompickroles.php |
lang/ |
Language strings (en, he) |
— |
backup/ |
Moodle backup/restore of plugin data | Moodle backup API |
pix/ |
Plugin icons | — |
tests/ |
PHPUnit tests | Moodle PHPUnit |
docs/ga4-setup/ |
One-time GA4 property setup helper for the telemetry (Python script + README); not shipped behavior | google-analytics-admin |
Moodle-defined entry points stay where Moodle looks for them (lib.php callbacks, externallib.php, db/), and db/ only declares. lib.php holds the procedural local_annoto_* helpers that both hook paths and the external functions share; new self-contained logic goes in an autoloaded class under classes/ (namespace local_annoto), which the entry point calls. A helper both hook paths need lives once, in lib.php or a class — never copied into each path.
- Every page-level hook exists twice: the Hooks API callback in
classes/hook_callbacks.php(Moodle 4.4+, registered indb/hooks.php) and the legacylib.phpcallback (3.11–4.3). Both paths are kept so one release covers 3.11–5.2; a change to one is made to the other. - Never hand-edit
amd/build/; editamd/src/and regenerate the build with grunt, committing both. initkaltura.jsis raw ES5 with no build step — no ES2015+ syntax, no imports.- The Kaltura V7 integration spans this repo and
moodle-local-js; read KALTURA_V7_HANDOFF.md before changinginitkaltura.js, the top-of-body hook, or anything inget_jsparamsthe bundle reads. Its "History" section lists approaches that were tried and replaced — do not re-attempt them.
Public contracts
- External functions in
db/services.php:get_jsparamsandlocal_annoto_set_completion— AJAX-enabled and exposed to the Moodle mobile service; their parameters and return structures are an API. window.moodleAnnotoand theget_jsparamsresult stored onwindow.moodleAnnoto.params(clientId,userToken,mediaGroupId/Title/Description,mediaTitle/Description,locale,loginUrl,bootstrapUrl,deploymentDomain,annotoMoodleCdnUrl, …) andwindow.moodleAnnoto.kV7App.playersMap— read by themoodle-local-jsbundle, which must keep working against every installed plugin version.- The SSO JWT built by
local_annoto_get_user_token()(HS256 signed withssosecret,iss= the raw clientId) — verified by Annoto's auth backend. - Admin setting names under
local_annoto/*insettings.php— stored in sites' config; a rename needs an upgrade step. - The database schema in
db/install.xml(local_annoto_completion,local_annoto_completiondata) — changed only together with adb/upgrade.phpstep and a version bump. version.php:$plugin->component,requiresandsupported— what the Moodle Plugins directory and site upgrades read.- The GA4 telemetry event names and parameters sent by
classes/telemetry.php— registered as custom dimensions in Annoto's GA4 property.
Changing any of them is a behavior change, never a refactor. Before changing a shared module, grep its consumers across the repo; a signature change enumerates every call site.
Docs
| Folder | What it holds |
|---|---|
| docs/adr/ | Architecture decision records, immutable once Accepted. |
| docs/design/ | Design specs for a feature or subsystem. |
| docs/plans/ | Implementation plans, task by task. |
| docs/guides/ | Guides, indexed by docs/guides/README.md. |
Guides and READMEs mirror rules and code for humans: when a change makes one wrong — or a code comment or JSDoc — correct it in the same change.
ADRs, design specs and plans are decision records, not mirrors of the code. They change through the workflow that owns them — a ruling, or a deviation recorded where that workflow says — never just because the code drifted; a stale claim no ruling covers is reported to the doc's owner.
- ADR — a
Proposedone is a draft, revised in place; anAcceptedone is not rewritten — a changed decision is a new ADR that supersedes it, and the old one's status becomesSuperseded. Deviating from any ADR is the user's call. - Design spec — changes to record a ruling on the design, including an accepted deviation from it.
- Plan — revised in place until building starts; from then on its body stays as written and deviations go in its
## Addendum.
Rules live in AGENTS.md files, never only in a doc: a doc may explain a rule, but no rule depends on reading one.
Common tasks
The moodle-plugin-ci checks are the ones CI runs — the GitHub Actions workflow .github/workflows/moodle-plugin-ci.yml (added by PR #161), which replaces .travis.yml: they need a Moodle checkout with this plugin installed into it (moodle-plugin-ci install), not this repo on its own.
| Command | Effect |
|---|---|
php -l <file> |
PHP syntax check of one file; needs no Moodle checkout |
moodle-plugin-ci install |
Set up a Moodle checkout with this plugin for the checks below |
moodle-plugin-ci phplint |
PHP syntax check |
moodle-plugin-ci phpcpd |
Copy-paste detector |
moodle-plugin-ci phpmd |
PHP mess detector |
moodle-plugin-ci codechecker |
Moodle coding style (phpcs) |
moodle-plugin-ci validate |
Plugin structure validation |
moodle-plugin-ci savepoints |
Upgrade savepoint check |
moodle-plugin-ci mustache |
Mustache template lint |
moodle-plugin-ci grunt |
Runs grunt; fails when amd/build/ is out of date |
moodle-plugin-ci phpdoc |
PHPDoc check |
moodle-plugin-ci phpunit |
PHPUnit tests |
moodle-plugin-ci behat |
Behat tests |
npx --no-install grunt amd (in the Moodle root) |
Rebuild amd/build/ from amd/src/ |
The aggregate check is that CI workflow, run on every PR; locally, the rows above run one at a time — run the ones you can (php -l always) and read the CI result before reporting a PR ready.
Releases
- Every shipped change bumps
version.phpby hand:$plugin->versionasYYYYMMDDXX(the release date) and$plugin->releaseas the semver string. A version is never reused, and adb/change always comes with a bump. A release reaches the Moodle Plugins directory through a GitHub release, which moodle.org picks up — so publishing a GitHub release is publishing to every site that updates; never create one unless asked.
Git
- Never include the Claude Code session link (
Claude-Session:trailer,https://claude.ai/code/session_...) in commit messages, PR bodies, or issue and review comments. - The
Co-Authored-Bytrailer namesAgent, never the full model/email. - A PR body ends with
Co-Authored-By: Agent, never with a "Generated with Claude Code" line or any other tool attribution. - Issue and PR bodies, comments, and review replies are markdown as the forge renders it — headings, lists, links. Never wrap the whole text in a code fence; fence only code and command output inside it.
- Run a push as a call of its own, with no other command in the same call — not chained, not in a script. A call that names a protected branch elsewhere, such as opening a PR against it, can match the protected-branch deny rows and block the push outright.
Git Commit Conventions
Commits follow Conventional Commits
type(scope): short description
| Type | When to use |
|---|---|
feat |
New feature or capability |
fix |
Bug fix |
refactor |
Code change with no functional difference |
test |
Adding or fixing tests |
chore |
Maintenance, dependency updates |
devops |
Infrastructure, CI/CD, agent config and dev tooling, migrations, deployment, DB scripts |
docs |
Documentation only — READMEs, guides, design specs, ADRs, plans |
Scope identifies the affected package(s) or area. Scope is optional for changes that span the whole repo or don't map cleanly to a single package.
Rules
- Description is lowercase, no trailing period.
- Use imperative mood: "add", "fix", "remove" — not "added" or "fixes".
- If a commit spans more than 2 scopes, omit the scope, keep only the type, and use a multi-line commit for details.
Multi-line commits
Prefer multi-line format whenever a commit includes multiple distinct changes — not just for PR squash merges. Use a single-line message only when the commit does exactly one thing.
Add a body listing the individual changes as bullet points. Omit iterative type(scope) bullets that only refine or clean up work introduced earlier in the same PR — include only bullets that add distinct value.
Bullet prefix rule: if every bullet has the same type(scope) as the subject line, omit type(scope): from all bullets and write only the description. If any bullet differs, include type(scope): on all bullets.
Git Branch Rules
- Protected branches:
main— commits land there only via PRs or automated tooling, never manually. Always work on a feature branch and open a PR. - Branch naming:
feat/<description>,fix/<issue-number>-<description>,refactor/<issue-number>-<description>,docs/<issue-number>-<description>,devops/<description>.
Issues
Issues carry the label of their kind: bug for a defect or regression; enhancement for a feature or behavior change, and for cleanup with no behavior change, since the repo has no refactor label; ux change for a change to what users or admins see or do; deprecation for following a Moodle core deprecation (a deprecated callback or API); compliance for meeting the Moodle coding standard and plugin CI. Title: one specific line naming the area and the symptom, capability, or target.
Pull Requests
- Label the PR to match the issue it closes, adding
security,breaking, etc. when they apply. - The PR body closes its issue (
Closes #<n>).
House rules
Workflow rules
- When the user asks a question, discuss first — don't jump to implementation or edits as a response.
- Be direct and concise — no pleasantries, no preamble, no filler. Disclaimers and caveats stay short; the response goes to the main answer. Asked to explain something, give the high-level summary unless depth is asked for.
- Link what you name. A file or a doc section in a reply is a markdown link (AGENTS.md, Git), never a bare path.
- Report an edit, don't paste it. What changed, where (linked), and why — the user reads the file.
- Feedback in chunks. Review findings or suggestions you volunteer in an interactive conversation come in chunks of up to five points, saying how many remain. A skill's prescribed report is presented as that skill says, and an unattended run sends the whole report in one message.
- Ask before adding a dependency. Prefer what the repo already has.
- Ask before generating
.mddocs, unless explicitly instructed otherwise. - A document is as long as its task needs. Cover the substance; no filler sections, restated summaries, or boilerplate. A skill's or template's required sections are substance — the rule governs what fills them and what is added beyond them.
- Search the web for current docs when a task depends on an external fact — a dependency, framework, API, tool, platform service, or a standard or spec (e.g. OWASP, WCAG). Training data is stale; verify against the installed or adopted version before applying advice.
- If a rule conflicts with a task, ask — don't silently bypass.
- TDD is mandatory for features, fixes, and behavior changes — the
tddskill's full cycle (RED, GREEN, PROVE RED, REFACTOR). - Every check in Common tasks must pass before committing — there is no one command that runs them all. Before reporting a PR ready, run them again.
Technical rules
- Design principles: DRY, KISS, YAGNI, SOLID — in that order of frequency. Don't abstract until the second duplicate. Don't add config knobs, hooks, or generics for a use case that isn't in the diff. An established codebase pattern is not over-engineering: repeating it for new code is expected; flag as YAGNI only abstractions nothing in the codebase uses.
- Tests live under
tests/as PHPUnit*_test.phpfiles in thelocal_annotonamespace, the Moodle plugin convention. Every new public function, type, or component ships with tests in the same commit; cover the happy path, the documented edge cases (empty, null, error), and failure paths. Tests exercise real logic. - Document non-obvious logic only. A short comment explaining why (invariant, workaround, protocol quirk, ADR reference) is welcome. Don't restate what the code does.
- Never count what the text lists. "The three options", "both callbacks", "these five steps" — in a doc, a comment, a docstring, or a commit body — go stale the moment an item is added or removed. Let the list carry its length.
- When a code question is really an architecture question, read the ADR before editing. A boundary or a shape that looks wrong was decided, not overlooked.
- Follow existing code patterns. Different areas may differ in style — adapt. When existing code and these rules disagree, the rules win: legacy code may predate them.
Checks and evidence
Each of these exists because its absence ships something wrong.
- Every claim in a report is audited against a tool result from this session. Report only work you can point to evidence for, and say explicitly what is not yet verified. Outcomes faithfully: a failing test with its output, a skipped step named, and what is done and verified stated plainly, without hedging.
- Every guard, gate, or check must be provably able to fail: break what it guards, watch it go red, revert. A check you cannot demonstrate red is not a check.
- Catches fail closed. A tool error, an empty result, or a skipped step never reads as "no findings".
- Numbers in commit messages and PR bodies are prose; evidence is the command that ran and its exit status.
Security
When writing or reviewing code, check for the following. The categories follow the OWASP Top 10:2025; look an item up there for depth. Severity: HIGH = blocker, MEDIUM = should fix, LOW = consider fixing but always notify the team.
HIGH — Blockers
- Hardcoded credentials or API keys (A07 Authentication Failures) in source code or committed config files.
- Sensitive data exposure (A01 Broken Access Control; written to logs, A09 Security Logging and Alerting Failures): secrets, tokens, or PII written to logs, included in error output, or returned beyond what the caller needs.
- Untrusted input reaching a shell, a query, a parser, or a filesystem path unvalidated (A05 Injection; path traversal, A01 Broken Access Control) — injection and path traversal. Parameterize queries; sanitize any path built from input.
- Authorization bypass (A01 Broken Access Control): a route, handler, or gateway without the guard its data requires; misconfigured scopes or access options.
- IDOR (Insecure Direct Object Reference; A01 Broken Access Control): resource access without verifying that the resource belongs to the authenticated principal or that they hold the right to it. A resource that belongs to someone else answers like one that does not exist.
- Data isolation (A01 Broken Access Control): where no id is passed, the binding to the authenticated principal still holds — every read, write, cache key, queued job, scheduled task, and outbound call carries the identity of the request that caused it. A missing binding lets one user's data reach another's request with no lookup involved.
- Missing input validation (A05 Injection) at the boundary — no schema or DTO validation on an endpoint that accepts caller-controlled data.
- Insecure design (A06 Insecure Design): a feature with no auth boundary, data reachable without any ownership check, or no way to restrict access after the fact — caught at design time, before implementation.
MEDIUM — Should fix
- Cryptographic failures (A04 Cryptographic Failures): weak algorithms, hardcoded IVs, home-rolled crypto, insufficient key lengths, missing key management; secrets encrypted at rest and in transit across all layers, including infrastructure.
- Unhandled errors (A10 Mishandling of Exceptional Conditions) — an async error not caught (a floating promise, a missing
await); an uncaught rejection, panic, or exception that leaks a stack trace or internal state to a caller. - Insecure defaults (A02 Security Misconfiguration): a shipped default — a config option, a feature toggle, an exposed surface — is the secure choice, and the user opts out. In a published library or SDK, every default is each consumer's production config.
- Authentication failures (A07 Authentication Failures): credential verification bypassed or weakened — token checks (signature, issuer, audience, algorithm, expiry), session handling, or API key validation.
- Excessive permissions (A01 Broken Access Control): a key, scope, or identity granted broader access than the operation needs.
- SSRF (Server-Side Request Forgery; A01 Broken Access Control): a caller-controlled URL used in a server-side request without allowlisting or validation.
- Integrity of external payloads (A08 Software or Data Integrity Failures): webhook callbacks, OAuth or SSO launches, and third-party responses are verified (signature, HMAC, JWT validation) before trust; internal queue messages are inside the trusted boundary.
- Overly permissive CORS (A02 Security Misconfiguration): origins validated against known hosts.
- Missing rate limiting (A07 Authentication Failures) on authentication, token issuance, or other sensitive endpoints.
- Security misconfiguration (A02 Security Misconfiguration): debug or admin endpoints left enabled, misconfigured guards or decorators, permissive error responses, unnecessary modules enabled, default credentials, insecure environment configuration.
- Security logging gaps (A09 Security Logging and Alerting Failures): failed auth attempts, permission denials, and sensitive data access leave no record.
LOW — Consider
- Vulnerable or outdated components (A03 Software Supply Chain Failures): when adding or upgrading a dependency, verify it has no known CVEs and is actively maintained.
- Verbose error messages (A10 Mishandling of Exceptional Conditions) that reveal implementation structure to clients.
- Missing audit logging (A09 Security Logging and Alerting Failures) on sensitive operations in existing code — who did what, and when. A new state-changing endpoint follows the Audit trail rule below.
- Long-lived tokens (A07 Authentication Failures) without expiry or rotation.
Audit trail (required at design time). Any state-changing endpoint on a sensitive entity — permission changes, content moderation, data deletion, anything financial or graded — specifies, before implementation: which identity is captured (the authenticated user, the API key, or a system identifier), which field or log entry carries it, and where it is extracted from. A state-changing endpoint without identity capture is a compliance and incident-response gap.
In this plugin the boundary is Moodle's: every external function validates its parameters (*_parameters() / validate_parameters), validates the context of the course or module it touches (the module's, when it acts on one), and checks a capability where one applies; set_completion writes grade-like completion state and is the sensitive endpoint. ssosecret signs the SSO JWT and never reaches the browser or a log.
One deliberate exception to the hardcoded-credentials blocker: GA_MEASUREMENT_ID and GA_API_SECRET in classes/telemetry.php ship in the plugin by design. The secret is a write-only GA4 Measurement Protocol key that every site needs to reach Annoto's telemetry stream, and sending stays gated on the admin opt-out. Don't move it to a setting or flag it in review; rotating it is a release decision for the team.
Code conventions
General
- No magic numbers or strings — named constants.
- No commented-out code. A
TODO/FIXMEreferences a ticket or states a clear action. - No debug prints in shipped code — use the project's logger.
- Prefer early return over nested conditionals; split a function with many branches.
- When a function takes more than two parameters, two of the same type, or any boolean, take one named argument object (or struct) instead.
- Lint and typecheck every file you touch before finishing; lint errors are often real bugs — a missing await, an unhandled error, a wrong import.
PHP
- Code follows the Moodle coding style and PHPDoc rules that
moodle-plugin-ci codecheckerandphpdocenforce. - The project's logger is
local_annoto\log(classes/log.php). One exception: the unconfigured clientid/ssosecret warning inlocal_annoto_get_user_token()callsdebugging()directly, becausetests/lib_test.phpasserts it fires andlogis silent under PHPUnit. - Use Moodle's APIs:
$DBwith placeholders,get_config('local_annoto', …),required_param/optional_param— never raw SQL strings with input in them or$_GET/$_POST. - A new user-facing string goes in
lang/enandlang/hetogether. - A new setting, capability, or
db/change comes with aversion.phpbump.
JavaScript
amd/src/is a Moodle AMD module built by grunt;initkaltura.jsis plain ES5 loaded as a script tag.- Browser debug logging is gated on
sessionStorageflags (moodleAnnotoDebug,moodleAnnotoDebugKaltura), never unconditionalconsoleoutput. - Most JavaScript belongs in
moodle-local-js; add JS here only when it must run before the CDN bundle loads (asinitkaltura.jsdoes).
Permissions when running unattended
This section applies when you run as a subagent, in a background task, or in a non-interactive session — anywhere a permission prompt has no one to answer it. An interactive session simply asks; a denied call there means the user declined, so adjust the approach rather than retry it.
Unattended, a denied tool call is a silent failure mid-workflow. A call must pass both the tools the session has and the project's permission lists (.claude/settings.json for Claude Code — permissions.allow is auto-approved, permissions.deny is blocked). Read them at the start and plan around them.
- Each piped variant of a shell command needs its own allow entry:
Bash(git log*)does not covergit log | head. - Fetch only allowed domains; call only allowed MCP tools; check the deny list for path restrictions before editing.
- When a needed tool is missing, try a permitted alternative; if none exists, stop and report — do not retry the denied call.
- At the end of your work, list any tool you needed but could not use, so the user can extend the settings:
MISSING PERMISSIONS:
- <Tool>(<pattern>): <why needed>
