Non-blocking content rules. “A published article whose author is still a draft” is flagged wherever that content is touched — and never refuses a save or a publish.
- A rule = content type + records-list filter + severity.
- A finding = one rule’s verdict about one entry.
- Severities
error,warn,infoonly sort and colour. None blocks. If something must not publish, put it in the schema.
Install
npm install @orthacms/alarms-server @orthacms/alarms-adminBoth are installed in every app create-orthacms-app generates.
Register
// apps/server/src/plugins.ts
import { AlarmsPlugin } from '@orthacms/alarms-server';
// after ContentPlugin — it uses content's registry, filters and grants
AlarmsPlugin({ sweepIntervalMinutes: 15 }); // options are optional// apps/admin/src/plugins.ts
import { AlarmsPlugin } from '@orthacms/alarms-admin';
AlarmsPlugin(); // after ContentPlugin()The admin half adds a Checks block to the entry editor’s Properties panel, an optional Checks column, the toolbar’s Save as alarm action and an Alarms workspace page. Since 0.12.0 it also reports the record’s open findings as one status item in the editor’s header (“2 checks flagged”) — a warning when any is an error, else info, never one that blocks publishing — and adds an open-findings card to Home’s Needs your attention, summed across your workspaces and shown at 0 too.
Since 0.13.0 the Alarms page has two tabs under its per-severity strip:
- Flagged — one group per alarm (its severity, name, “Articles · shows editors “…””, Re-check and Edit), each row the record by title, “flagged 2 weeks ago” and Open, and Show all in Records, which opens the records list filtered by the alarm’s own condition.
- Alarms — each alarm as a card: its condition as chips, “N flagged” or “Nothing flagged”, when it last checked, an on/off switch and a ⋯ menu (Edit, Re-check, Delete). Pausing is the switch, not a delete; switching it back on rescans, and the toast says what it found.
New alarm opens a two-column editor: What to watch, Alarm name,
What editors will see, Level and Flag a record when… on the left,
and Right now on the right — the live count, up to four matching records
by title, and the finding as editors will see it. Create alarm creates it;
editing saves from the top bar (Save changes) and stays on the page. The
editor has no notes field: it never sends description, so a stored one is
kept.
Configuration
Options passed to AlarmsPlugin(...). No environment variables — rules live in
the database.
| Option | Default | Bounds |
|---|---|---|
maxScanEntries | 20,000 | Entries one full rescan of one rule examines |
scanBatchSize | 500 | Entries read per batch (peak memory) |
sweepIntervalMinutes | 60 | Background rescan interval. 0 turns it off |
maxDependentsPerEvent | 500 | Fan-out of the reverse-relation pass |
A zero, negative or fractional bound fails at boot (except
sweepIntervalMinutes: 0).
Routes
Admin routes: session cookie and X-Workspace-Id — see calling admin
routes.
| Method | Path | Needs | Returns |
|---|---|---|---|
GET | /api/alarms/rules | alarms:read | AlarmRule[] with openCount |
POST | /api/alarms/rules | alarms:manage | { rule, scan } — scans immediately |
POST | /api/alarms/rules/preview | alarms:manage | { matched, total, sampleIds, sample } — sample is { id, title }[] |
PATCH | /api/alarms/rules/:id | alarms:manage | The rule plus scan — a filter change, or switching a disabled rule back on, rescans first; scan is null when none ran |
DELETE | /api/alarms/rules/:id | alarms:manage | 204 |
POST | /api/alarms/rules/:id/rescan | alarms:manage | { ruleId, scanned, opened, resolved, open } |
GET | /api/alarms/findings | alarms:read | Page of findings |
GET | /api/alarms/findings/by-entry?entryIds=a,b | alarms:read | { byEntry: { [id]: Finding[] } } (≤ 100 ids) |
GET | /api/alarms/findings/summary | alarms:read | { open: { error, warn, info }, openTotal } |
Create a rule
curl -X POST https://cms.example.com/api/alarms/rules \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{
"contentType": "article",
"name": "Live articles point at published authors",
"findingTitle": "Author is not published",
"severity": "warn",
"filter": { "and": [
{ "field": "status", "op": "eq", "value": "published" },
{ "field": "author.status", "op": "ne", "value": "published" }
] }
}' curl -X POST https://cms.example.com/api/alarms/rules/preview \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{"contentType":"article","filter":{"and":[{"field":"status","op":"eq","value":"published"}]}}'
# { "matched": 12, "total": 340, "sampleIds": ["9c4b…", "…"],
# "sample": [{ "id": "9c4b…", "title": "Spring launch" }, "…"] } {
"rule": {
"id": "71c3…",
"contentType": "article",
"name": "Live articles point at published authors",
"findingTitle": "Author is not published",
"description": null,
"severity": "warn",
"filter": { "and": [ "…" ] },
"enabled": true,
"brokenReason": null,
"lastScanAt": "2026-09-30T09:40:00.000Z",
"createdAt": "2026-09-30T09:40:00.000Z",
"updatedAt": "2026-09-30T09:40:00.000Z",
"openCount": 3
},
"scan": { "ruleId": "71c3…", "scanned": 340, "opened": 3, "resolved": 0, "open": 3 }
}filteris the object form of the records list’s?filter=tree — see filtering. A rule means exactly what the list means.nameis unique per workspace (409on a duplicate);nameandfindingTitle≤ 120 chars,description≤ 500.contentTypecannot change onPATCH— sending it is a400.- A filter the engine rejects is a
400with its message (e.g. an unknown field). - An unregistered and an ungranted type get the same
404.
Read findings
curl 'https://cms.example.com/api/alarms/findings?severity=error&page=1&pageSize=25' \
-b cookies.txt -H 'X-Workspace-Id: 3f7a…'{
"items": [
{
"ruleId": "71c3…",
"ruleName": "Live articles point at published authors",
"title": "Author is not published",
"contentType": "article",
"entryId": "9c4b…",
"entryTitle": "Spring launch",
"severity": "warn",
"state": "open",
"detail": null,
"firstSeenAt": "2026-07-01T10:00:00.000Z",
"lastSeenAt": "2026-09-30T09:40:00.000Z"
}
],
"total": 1,
"page": 1,
"pageSize": 25
}Query: state (open, resolved; omitted = everything but resolved),
ruleId, severity, page, pageSize (default 25, ≤ 100).
Since 0.13.0 a finding on this page carries entryTitle — content’s own title
rule (the first non-empty short text or select field), read once per content
type on the page. It is null for an entry with no such value, or one that has
gone, and a failed title read never loses the finding. The by-entry batch does
not carry it.
How rules are evaluated
| Path | Covers |
|---|---|
| Entry lifecycle events from the outbox | Anything being edited |
| An explicit rescan (and on create / filter edit) | A new or changed rule |
| A periodic sweep | Entries nobody touches — e.g. “not updated in 90 days” |
- Findings close themselves across relations. Publishing, unpublishing,
deleting, restoring or purging an entry re-evaluates rules whose filter
follows a relation to that type, for the entries linking to it (≤
maxDependentsPerEvent). - A rule whose filter stops parsing is marked broken (
brokenReason) and skipped. - A rescan that hits
maxScanEntrieslogs a warning instead of reporting clean. entry.deletedresolves findings;entry.purgeddeletes them.- A finding is keyed
(rule, entry), so repeat delivery is a no-op, andfirstSeenAtsurvives a resolve-and-reopen. - The sweep is an in-process interval: several API processes simply sweep more often. Do not rely on it for anything that must run exactly once.
- There is no mute. Narrow or switch off the rule instead. Switching it back on
rescans before the
PATCHanswers, so its findings never describe the day it was paused.
Permissions
alarms:read— admin, contributor and viewer (findings show in the entry editor).alarms:manage— admin only.- No API token scope carries either.
Copilot tool
admin_alarms_findings — a read of the same store. Copilot only, never MCP (a
token could never hold alarms:read). Findings carry an entry id, so the natural
next call is admin_content_get. There is no write tool.
Protection’s review state in a rule
With protection installed, reviewState is a
filterable field, so a rule can flag entries still waiting on review:
{ "and": [{ "field": "reviewState", "op": "eq", "value": "awaiting" }] }