Extension points
Core code never requires a module. When core needs module behaviour, it calls an extension point by name, and the modules that are on for the caller answer. The points are declared in one place, zcm-backend/lib/modules/points.js; the context each point receives and the answer it expects are documented in zcm-backend/docs/modules/README.md section 9. This page is the map. Read Building modules first.
How a module contributes
A module lists its contributions in modules/<id>/contributions.js, as { '<point>': fnOrValue }. The manifest points to it with contributes: () => require('./contributions'). A contribution to a point name that does not exist fails manifest validation at boot.
// modules/fisheries/contributions.js
module.exports = {
// a figure on the back-office dashboard
'dashboard.widgets': async ({ mdaId }) => {
const { FisheriesRecord } = require('../../models');
const count = await FisheriesRecord.count({ where: mdaId ? { mdaId } : {} });
return [{ key: 'fisheries.records', label: 'Fisheries records', data: { count } }];
},
// money: answer undefined when the payment is not yours; use ctx.transaction for every query
'payment.onFinalised': async ({ invoice, transaction }) => {
if (!invoice || !isOurs(invoice)) return undefined;
await markLicencePaid(invoice, { transaction });
return { updated: true };
},
};
Calling modes
| Mode | Core calls | Result |
|---|---|---|
each | points.run(name, ctx) | Every contribution, in dependency order; an array of results. |
each, isolated | points.runIsolated(name, ctx) | Every contribution in its own savepoint. A failure is rolled back alone and logged, never thrown. |
first | points.first(name, ctx) | The first answer that is not undefined. |
collect | points.collect(name, ctx) | The arrays of every contribution, concatenated. |
collect (sync) | points.collectSync(name, ctx) | The same, read at load time: values or synchronous functions only. A promise throws. |
| — | points.has(name, ctx) | Does an enabled module contribute? |
Three rules apply to every point:
- Only enabled modules take part, for
ctx.mdaId, else the ministry ofctx.user, else the installation. Withno module on, every point is a no-op (
[]orundefined). - Transactions belong to the caller. Use
ctx.transactionfor every query; never open or commit your own. Anexception in a contribution reaches the caller and rolls its transaction back (except on isolated points).
- Bypass points also reach a bundled module that is switched off. They exist for the money rule: a payment that
arrives for a module switched off since is still finalised by that module. When a switched-off module acts on a bypass point, the run is audited as
module.bypass. The bypass points arepayment.onFinalised,payment.finaliseExternalInvoice,billing.onAllocatedandpos.offlineSale.
Separately, the personal-data and register registries (softDelete.kinds, pii.*, anonymise.*, dsr.exportSections, registers.packs, migration.legacyImports) are read for every bundled module, on or off, through lib/moduleData.js. Masking, deletion and a person's data export never depend on a switch.
Reference
Services and applications
| Point | Mode | Purpose |
|---|---|---|
service.capabilities | collect (sync) | The service capabilities a module owns (from the manifest's serviceCapabilities). |
case.initialStatus | first | The status a new case starts in, for the module's services. |
case.onSubmit | each | Act when an application is submitted, inside the submission's transaction. |
case.statusChanged | each | A case moved to another status; runs after the move committed. A failure is logged, the move stands. |
case.onDecision | each, isolated | How an application ended (approve, reject, withdraw, lapse), on every path that records it. A module can never stop a decision. |
case.onApproved | each | Act when a case is approved (issuance). |
case.stepGate | first | May the case leave this step? For step types the module contributes. |
signature.completed | each | Every signer of a signature request signed; runs after the commit. |
form.validators | collect | Cross-field checks the form engine cannot express, run on the server at submission. |
form.prefill | collect | Look-ups that fill a service's form from a ministry system. |
form.fieldRoles | collect (sync) | Roles a form field can play for the module. |
service.requiredDocuments | collect | Documents a ministry system asks for, given the answers. |
allocation.claims | collect | What an application draws from the module's allocation (quota) pools. |
names.sources | collect | Names in an outside register, for the generic name search. |
Service designer and vocabulary
| Point | Mode | Purpose |
|---|---|---|
designer.feeTypes | collect (sync) | Fee types the designer offers. |
designer.stepTypes | collect (sync) | Process step types the designer offers. |
status.vocabulary | collect (sync) | Plain words for the module's statuses. |
Payments and billing
| Point | Mode | Purpose |
|---|---|---|
payment.quote | first | Work out an amount on the server, with optional lines. |
fee.proposalCheck | first | May finance approve this officer-computed fee? |
payment.onFinalised | each, bypass | A payment was finalised; update the module's own records. |
payment.finaliseExternalInvoice | first, bypass | Finalise an invoice the module raised outside a case. |
payment.payerResolver | first | Name and contacts of the payer for a payment. |
payment.kinds | collect | Kinds of payment the module raises, for finance screens. |
payment.accountOrder | first | An order against one of the citizen's accounts, priced on the server. |
payment.pointDescribe / payment.pointOrder / payment.pointTicket | first | Pay-at-point QR codes: describe the charge, raise the session's ticket invoice, report the ticket. Never mark anything paid. |
billing.kinds | collect (sync) | Kinds of billing account the module owns, and their amounts. |
billing.onAllocated | each, bypass | Money was allocated on one of the module's billing accounts. |
Documents, permits and verification
| Point | Mode | Purpose |
|---|---|---|
documents.templates | first | Merge data, or a rendered file, for an output document. |
documents.values | collect | Named values added to a case's output document. Core values win. |
permit.issueOptions | first | Validity, conditions or metadata for the permit core issues. |
permit.details | collect | Service-agnostic blocks shown with the holder's own permits on "My permits". |
verify.lookup | first | The module's own issued documents, for the public verification page. |
Citizen views
| Point | Mode | Purpose |
|---|---|---|
citizen.actions | collect | Items that wait on the signed-in person. |
citizen.records | collect | Records the module holds for the person (My records). Every word shown comes from here. |
citizen.payments | collect | Accounts the person pays a regular charge on. |
Back office, reporting and integration
| Point | Mode | Purpose |
|---|---|---|
dashboard.widgets | collect | Figures on the back-office dashboard. |
reports.sources | collect (sync) | Data sources for the report builder. |
search.providers | collect (sync) | Results for staff search. |
registerEntry.tabs | collect | Tabs on the register-entry 360 page. Show only what the caller may see. |
registers.packs | collect (sync) | Register schema packs the module adds. |
notifications.templates | collect (sync) | Notification templates, editable on the templates screen. |
webhooks.eventTypes | collect (sync) | Webhook event types added to the catalogue. |
audit.objectTypes | collect (sync) | Audit object types. |
openapi.paths | collect (sync) | Paths added to the OpenAPI document. |
dataExchange.handlers | collect (sync) | Handlers for messages a ministry system pushes over the legacy data exchange contract. |
migration.legacyImports | collect (sync) | The module's import of legacy tables only it understands. |
pos.capabilities | collect (sync) | POS capability names for the device's ministry. |
pos.offlineSale | first, bypass | An item of the terminal's offline queue (cash already taken is recorded). |
Personal data
| Point | Mode | Purpose |
|---|---|---|
softDelete.kinds | collect (sync) | Record types for the recycle bin. |
pii.subjects / pii.paths | collect (sync) | Where the module keeps personal data, for masking. |
anonymise.columns | collect (sync) | How the anonymiser treats the module's columns. |
anonymise.referenceTables | collect (sync) | Tables without personal data, copied as they are. |
dsr.exportSections | collect | The module's part of a person's data export. |
Maps, land and field work
| Point | Mode | Purpose |
|---|---|---|
geo.validators | collect | Rules for the "Location / area on map" form field. |
geo.layerRefreshed | each | A reference layer was refreshed from the geodata provider. |
geo.feeds | collect (sync) | Read-only GeoJSON feeds for partner systems (scope geo:read). No personal data. |
geo.layers | collect | Live read-only map layers for back-office maps, with an attribute allow-list. |
geo.publicSearch | collect (sync) | A register of shapes the public may search on "Find a property or parcel". |
parcels.lookup | first | The parcel register (a cadastre module); read through lib/parcels.js. |
parcels.events | each | A parcel was created, retired, subdivided, consolidated or changed shape. |
land.titleFor | first | The title registered on a parcel. |
inspection.subjectLocation | first | Where an inspected subject is, for on-site checks. |
field.captureTargets | collect | What an officer may capture in the field as a new, proposed record. |
subject.completed | each, isolated | Something was completed (for example a building at its occupancy certificate); emitted after the emitter's commit. |
Laboratory
| Point | Mode | Purpose |
|---|---|---|
samples.subjects | collect | Kinds of record a laboratory sample without an application may name. |
laboratory.samples | first | The laboratory itself, for other modules; read through lib/laboratorySamples.js. |
Geo points are described in more detail in Geospatial data and NGDR.
Proposing a new generic point
The ground rule is: if a module needs a new hook, add a generic extension point to core and document it. Never put module logic into core files such as caseController or caseWorkflow.
- Check the list first. Many needs are already covered:
case.onSubmitpluscase.onDecisioncover mostlifecycle work,
documents.valuescovers extra document fields,form.validatorscovers cross-field checks. - Design it for every ministry. Name it after the core concept (
permit.issueOptions, notforestry.licenceValidity). The context must carry only what any module would need, and never a ministry name. - Pick the mode.
firstwhen one module owns the answer (answerundefinedwhen it is not yours);collectwhen several modules add items;
eachfor notifications of something that happened; isolated when a module must never be able to stop or undo what core records; bypass only for money that must still settle after a switch-off. - Declare it in
POINTSinlib/modules/points.js, with a comment giving the context and the expected answer. - Call it from core by name, inside the caller's transaction where there is one. Make the "no module on" case
behave exactly as before.
- Document it: add a row to section 9 of
docs/modules/README.md(point, mode, context, answer). Points thatform part of a frozen cross-team contract are also recorded in
.overhaul/CONTRACTS.md. - Test it: a unit test of the point, and an integration test with a test-only module
(
setup({ modules: [manifest] })) proving the off, on and failure behaviour.npm run check:modulesandnpm run check:mdamust stay green.
Learn to use these points in practice in the Module developer course.