Non-blocking content rules. A workspace declares what it considers wrong — “a published article whose author is still a draft” — and the CMS says so wherever that content is touched, without ever refusing a save or a publish.
A rule is a content type plus a saved records-list filter plus a severity. A finding is one rule’s verdict about one entry.
Install
npm install @apograph/alarms-server @apograph/alarms-adminBoth are installed in every app create-apograph-app generates.
Register
// apps/server/src/plugins.ts
import { AlarmsPlugin } from '@apograph/alarms-server';
AlarmsPlugin();// apps/admin/src/plugins.ts
import { AlarmsPlugin } from '@apograph/alarms-admin';
AlarmsPlugin();Register the server half after ContentPlugin — it uses content’s registry,
filter surface and grant query. Register the admin half after
ContentPlugin() for the same reason the other Content Library slot fillers
need it: it contributes the entry rail’s checks block, an optional records
column and the toolbar’s “Save as rule”, plus a workspace route of its own.
An alarm never blocks a write
No severity has authority. error is louder than warn, and that is all it is.
The moment a severity can refuse a publish, this plugin and content’s publish gate become two competing authorities on whether an entry is valid, and they will disagree. If something genuinely must not be publishable, it belongs in the content type’s schema, where the validation service enforces it.
A rule is a saved list filter, not a query language
alarm_rules.filter stores the exact JSON the admin’s query builder puts in
?filter=. Four things follow, and they are the whole reason for the design:
- the rule editor is the existing query-builder component, with no new grammar to learn or document;
- “save this list filter as a rule” is a real one-click flow rather than a re-entry of the same condition;
- “show me the matching entries” links straight into the records list;
- a rule means exactly what the records list means by the same filter, because both run through content’s own entry-match query.
What this model cannot express
Anything that is a group by rather than a predicate on one row. “Two products share a slug”, “this section has fewer than three articles”. Those need a second kind of rule and a second evaluator; they are out of scope rather than pending.
Three ways a rule is evaluated
| Path | Covers |
|---|---|
| Entry lifecycle events, off the shared outbox | Anything anyone is editing |
| An explicit rescan | A rule that was just created or edited |
| A periodic sweep | Entries nobody is touching |
The sweep is what covers “not updated in 90 days” — an entry matches that rule precisely because nothing is happening to it, so no event will ever arrive to re-evaluate it. It is a plain in-process interval, so a deployment running several API processes simply rescans more often than asked, which is harmless for an idempotent upsert. It is not suitable for anything that must happen exactly once.
Findings close themselves
“This published article links to a draft author” is a fact about the article, but the event that fixes it arrives about the author.
So publishing, unpublishing, deleting, restoring or purging an entry triggers a second pass: rules whose filter tree mentions a relation pointing at the changed type are re-evaluated for the entries that link to it. Without it, “links to a draft” would open correctly and never close.
The pass is bounded twice — only rules that mention such a relation are
considered, and each is limited to maxDependentsPerEvent entries.
Behaviour worth knowing
- Creating a rule scans immediately, so it does not report a clean collection on the one day it is most likely to be wrong.
- Editing a filter forces a rescan before the response returns, so the findings table never describes the previous condition.
- A rule whose filter stops parsing is marked
broken, skipped, and shown as such. Silently never matching looks exactly like “everything is fine”. - A rescan that hits
maxScanEntrieslogs a warning rather than reporting a clean scan of a truncated set. entry.deletedresolves an entry’s findings;entry.purgeddeletes them. A soft delete can be undone and its history has to come back with it.- A finding is keyed
(rule, entry), so at-least-once outbox delivery makes a repeat evaluation a no-op.firstSeenAtsurvives a finding resolving and re-opening, which is what lets “open for three months” mean anything.
Muting one finding existed and was withdrawn
An alarm is either right about a record or wrong about it, and silencing one at a time is a way of living with a bad condition instead of narrowing or disabling the rule, where the next person can see the decision.
Routes
All under /api/alarms, all behind the workspace guard. Reads need
alarms:read; every write needs alarms:manage.
| Method | Path | Needs |
|---|---|---|
GET | /api/alarms/rules | alarms:read |
POST | /api/alarms/rules | alarms:manage |
POST | /api/alarms/rules/preview | alarms:manage |
PATCH | /api/alarms/rules/:id | alarms:manage |
DELETE | /api/alarms/rules/:id | alarms:manage |
POST | /api/alarms/rules/:id/rescan | alarms:manage |
GET | /api/alarms/findings | alarms:read |
GET | /api/alarms/findings/by-entry | alarms:read |
GET | /api/alarms/findings/summary | alarms:read |
An unregistered content type and one the workspace was never granted produce the same 404 with the same message, so the rule editor cannot be used to enumerate the deployment’s content model.
Permissions
alarms:read is held by contributor and viewer — findings are shown inline
in the entry editor, so the role that edits entries has to be able to read them.
alarms:manage is admin-only: a rule is editorial policy, not an edit.
Configuration
The plugin takes an options object; the rules themselves live in the database, because they are workspace content rather than deployment config. It reads no environment variables.
| Setting | Default | What it bounds |
|---|---|---|
maxScanEntries | 20,000 | Entries examined by one full rescan of one rule |
scanBatchSize | 500 | Entries read per batch, bounding peak memory |
sweepIntervalMinutes | 60 | Background rescan interval. 0 turns it off |
maxDependentsPerEvent | 500 | Fan-out of the reverse-relation pass |
The copilot can read findings
The plugin contributes one tool to the shared registry,
admin_alarms_findings — a read over the same store the HTTP routes use. It
exists because the feature’s whole claim is that “what is wrong with this
workspace’s content?” is answerable; a finding carries an entry id, so the
model’s natural next call is admin_content_get.
It is offered to the copilot only, never MCP. API token scopes mint
content:* plus a little media and nothing else, so alarms:read can never be
held by a token — offered over MCP the tool would appear in tools/list and be
refused on every call, which is worse than absent.
There is no write tool. Creating an alarm is defensible as a proposal, but only once the proposal can carry the live preview — a JSON filter tree on a card is not something a reviewer can meaningfully approve.