Watch rules
Declare behavioural-pattern detections in your ontology extension — score role build-ups against signature weights, suppress re-fires, and optionally trigger a response process flow when a detection fires.
Watch rules are authored inside an Ontology extension alongside your Object Types, Link Types, and Bindings. For the extension lifecycle — extension build, upload, install — see Extensions & Authoring SDK. For the detection runtime and how fired detections propagate, see the Ontology documentation.
A watch rule continuously monitors ontology observations and emits governed detections. Signature rules score role composition; report_gap rules detect entities that stopped reporting; zone rules detect restricted-area episodes. You declare the rule in code, versioned alongside the rest of your extension.
Declaring a watch rule
Add a watchRules array to your defineOntology call:
import { defineOntology } from '@scrydon/sdk-authoring/ontologies'
export default defineOntology({
id: 'c2-threat-awareness',
version: '1.0.0',
displayName: 'C2 Threat Awareness',
// ... object types, link types, bindings ...
watchRules: [
{
slug: 'kaliningrad-air-buildup',
displayName: 'Kaliningrad — allied air build-up',
description: 'Detects a concentration of ELECTRONIC_WARFARE, AEW, and FIGHTER airframes near the Kaliningrad approaches.',
target: {
objectTypeSlug: 'Aircraft',
entityProperty: 'icao24', // collapse positional fixes → airframes
timeProperty: 'seenAt',
latProperty: 'latitude',
lngProperty: 'longitude',
roleVia: { linkTypeSlug: 'AircraftOfType', property: 'missionRole' },
},
area: { objectTypeSlug: 'AreaOfInterest', areaId: 'kaliningrad-approaches' },
windowSeconds: 900, // 15-minute rolling window
signature: {
weights: { ELECTRONIC_WARFARE: 5, AEW: 4, FIGHTER: 1 },
roleSaturation: 2, // cap each role's contribution at 2 entities
},
threshold: {
minDistinctRoles: 2,
minScore: 10,
},
suppression: {
cooldownSeconds: 3600, // suppress re-fires for 1 hour
refireOnNewRole: true, // unless a new role appears
refireOnScoreDelta: 4, // or the score changes by more than 4
},
emit: {
objectTypeSlug: 'BuildUpDetection',
clearanceCeiling: { schemeId: 'default', rank: 2 },
},
},
],
})Field reference
Top-level fields
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Stable identifier for this watch rule. Must be a valid slug (lowercase letters, digits, and hyphens; no spaces). Used as the name of the emitted SCHEMA_WATCH declaration. |
displayName | string (1–100 chars) | Yes | Human-readable name shown in the platform UI. |
description | string (≤500 chars) | No | Optional longer description of what this watch detects. |
enabled | boolean | No | Whether the watch is active. Defaults to true when omitted. Set to false to ship a disabled watch that can be turned on without an extension re-publish. |
target | object | Yes | Defines which Object Type to observe and how to resolve entity identity and roles. See target below. |
area | object | Yes | Identifies the AreaOfInterest instance that defines the spatial boundary. |
windowSeconds | integer (1–3600) | Yes | Rolling time window in seconds (max 3600 — 1 hour). |
signature | object | Yes | Role weights and saturation cap. See signature below. |
threshold | object | Yes | Conditions that must both be met for a detection to fire. See threshold below. |
suppression | object | Yes | Re-fire control. See suppression below. |
emit | object | Yes | Output object type and clearance ceiling for the detection. See emit below. |
dispatch | object | No | Optional process-flow to start on detection. See dispatch below. |
target
| Field | Type | Description |
|---|---|---|
objectTypeSlug | string | The Object Type whose observations are scored. |
entityProperty | string | Property that identifies the persistent entity (e.g. icao24). Observations sharing this value are collapsed into one entity count. |
timeProperty | string | Timestamp property used to apply the rolling window. |
latProperty | string | Latitude property (WGS-84 decimal degrees). |
lngProperty | string | Longitude property (WGS-84 decimal degrees). |
roleVia | { linkTypeSlug, property } | Traverse this link type and read this property to determine the entity's role for scoring purposes. |
roleOverrideObjectTypeSlug | string? | When set, restrict role-resolution to entities of this Object Type on the far end of the link. |
area
An AreaOfInterest instance loaded from the ontology graph that defines the spatial boundary for the watch. Observations outside the area are excluded from scoring.
windowSeconds
Rolling time window in seconds. Maximum is 3600 (1 hour). Observations older than windowSeconds age out of the score.
signature
| Field | Type | Description |
|---|---|---|
weights | Record<string, number> | Score contribution per role. Keys must be role values present in the linked Object Type. Values must be ≥ 0 and finite. |
roleSaturation | number | Maximum entity count per role that contributes to the score. Prevents a single-role flood from masking a multi-role composition. |
threshold
| Field | Type | Description |
|---|---|---|
minDistinctRoles | number | Minimum number of distinct roles that must be present before the detection fires. |
minScore | number | Minimum weighted score that must be reached. |
Both conditions must be satisfied simultaneously for the detection to fire. A detection also needs at least one contact inside the area and window: with both thresholds at 0, an empty area does not fire, because a detection has to record the contacts it was derived from.
suppression
| Field | Type | Description |
|---|---|---|
cooldownSeconds | number | After a detection fires, suppress re-fires for this many seconds (max 86 400 — 24 h). |
refireOnNewRole | boolean | When true (default), a new role entering the window bypasses the cooldown and fires a new detection. |
refireOnScoreDelta | number | When the score changes by at least this amount, a new detection fires even within the cooldown window. Must be positive. |
emit
A watch whose clearanceCeiling is above rank 0 runs only once an admin selects a watch execution account.
| Field | Type | Description |
|---|---|---|
objectTypeSlug | string | The Object Type to materialise for each fired detection. |
severity | low, medium, high, or critical | Optional operator-facing severity, declared by the rule and written to each detection. Omitted on older watches; no severity is inferred from evidence or notebook code. |
clearanceCeiling | { schemeId, rank } | Required. Caps the clearance of the emitted detection object. The evaluator also reads at this ceiling: observations marked above it are never read and cannot contribute, and each detection's marking is the join of the evidence it cites — so setting this explicitly is a hard requirement (fail-closed). schemeId: 'default' means the installing organization's active classification scheme, so one extension works in every organization. |
dispatch (optional)
dispatch: {
extensionId: 'c2-drone-response',
processFlowSlug: 'buildup-response',
}When present, a fired detection starts the named process flow in addition to emitting the detection object. The flow receives a detectionId pointer only — no detection content is passed directly.
Clearance note. The dispatched flow resolves the detection under its own principals' clearance through the normal governed read path. It cannot access more of the detection than the principals executing it are cleared to see. The flow is only started when the identity the watch runs as (the execution account selected for watches, or the system actor) is cleared for the detection's marking; otherwise the response is refused and recorded as failed. Once started, the instance is named after the watch's slug, and anyone who can see it learns that the watch fired — see Notifications.
| Field | Type | Constraints |
|---|---|---|
extensionId | string | 1–256 characters. Must identify the extension that publishes the target process flow. |
processFlowSlug | string | 1–256 characters. Slug of the process flow to start on detection. |
Both fields are exact identifiers — no wildcards or patterns.
Watch execution account
Scheduled watches read ontology data as an identity, and a watch reads only what that identity is cleared for. By default that identity is the platform's own, which is cleared for public data only (rank 0). A watch whose clearanceCeiling is above rank 0, like the rank-2 example above, therefore does not run until an organization admin selects a watch execution account for its workspace environment: a service account with an execution clearance, which the watch then reads as.
On Settings → Organization → Members, open the Service accounts tab. Watch execution account is at the top of the tab; only organization owners and admins see it.
Choose the Workspace environment whose watches should run as the account.
Choose the Service account and save. The list shows only accounts that can run there: active, holding tables:read (or tables:write), holding an execution clearance, and with workspace and environment access that covers this environment. Once you choose one, the picker states the rank above which watches are skipped.
The selection is per workspace environment and applies to every scheduled run:
| Selection | What runs |
|---|---|
| None | Watches whose ceiling is rank 0 run as the platform identity. Every other watch is skipped. |
| An account | Every watch in the environment runs as the account. A watch whose ceiling is above the account's clearance rank is skipped; it is never evaluated below its ceiling. |
| An account that no longer qualifies (disabled, deleted, or its access or scopes changed) | No watch in the environment runs, and none falls back to the platform identity. The picker shows the saved choice as Unavailable account; select another account or clear the selection. |
A skipped watch is not silent: the platform logs a warning, ontology watches skipped because sweep clearance is too low, naming the organization, the environment and the skipped watches. It logs that warning when it first sees the skipped set and whenever the set changes, then repeats it at most once an hour while the same watches stay skipped. When the watches stop being skipped, because an account was selected or the watch was removed, it logs one information line, ontology watches are no longer skipped for sweep clearance.
Both the selection and its use are recorded in the organization's audit log:
- Saving a selection records
ontology.watch.execution_accounts.updated, with the admin who saved it and the selections before and after. - Every token a scheduled run mints for the account records
service_account.token.issued, orservice_account.token.refusedwhen the account no longer qualifies. The entry names as its initiator the admin who last saved the selections; if that save changed a different environment, theontology.watch.execution_accounts.updatedentries show who chose this one. The token itself is never recorded. - If the audit log cannot record a mint, no token is issued, and that environment's watches do not run until a later scheduled run succeeds.
Notifications
When a watch fires, the platform notifies the organization's owners and administrators whose clearance dominates both the watch's marking and the detection's — the clearance the governed read requires to see the watch and to open the detection it produced. A detection's marking is derived from everything it was built from, so it can sit above the watch's own; a member below it is not notified that the watch fired, because that fact is derived from the same data. Administrative role decides who can act on a detection; it does not decide who may learn that it exists. A member whose clearance cannot be established is not notified.
The notification does not name the watch, because a watch's slug often identifies what is being monitored. It reads A watch fired and carries only a pointer to the detection; Open detection takes the recipient to it in its organization, workspace and environment, where the detection itself is read under their clearance.
This clearance gate applies to notifications only. When the watch declares dispatch, each detection can also start a process-flow instance named after the watch's slug. It is started only when the identity the watch runs as is cleared for the detection's marking. Who can see a started instance follows the process flow's own access, so a member who is not notified may still learn from it that the watch fired.
Complete example with dispatch
watchRules: [
{
slug: 'kaliningrad-air-buildup',
displayName: 'Kaliningrad — allied air build-up',
target: {
objectTypeSlug: 'Aircraft',
entityProperty: 'icao24',
timeProperty: 'seenAt',
latProperty: 'latitude',
lngProperty: 'longitude',
roleVia: { linkTypeSlug: 'AircraftOfType', property: 'missionRole' },
},
area: { objectTypeSlug: 'AreaOfInterest', areaId: 'kaliningrad-approaches' },
windowSeconds: 900,
signature: {
weights: { ELECTRONIC_WARFARE: 5, AEW: 4, FIGHTER: 1 },
roleSaturation: 2,
},
threshold: { minDistinctRoles: 2, minScore: 10 },
suppression: {
cooldownSeconds: 3600,
refireOnNewRole: true,
refireOnScoreDelta: 4,
},
emit: {
objectTypeSlug: 'BuildUpDetection',
clearanceCeiling: { schemeId: 'default', rank: 2 },
},
// When the detection fires, start the response flow in the c2-drone-response extension.
// The flow receives a detectionId pointer and resolves content under its own clearance.
dispatch: {
extensionId: 'c2-drone-response',
processFlowSlug: 'buildup-response',
},
},
],Stability guarantee
watchRules is an optional field. Extensions that do not declare any watch rules keep compiling and hashing unchanged — adding this field to your extension version does not invalidate previously published manifests.
Within a watch rule, dispatch is also optional. Extensions that declared a watch rule before dispatch existed continue to parse and compile without modification.
Missing reports and feed health
For an absence watch used in an operational picture, set
absence.requireSourceHealth: true alongside baselineSeconds and
minMissingEntities. This setting is optional for compatibility with existing
published revisions; include it explicitly in new maritime watches.
The evaluator then requires a complete, consistent governed source read and
recent evidence from the ingestion service. A successful poll must have started
after the missing vessel's reporting deadline. Unknown, failed, paused, expired,
or partially processed feeds cannot trigger that watch. Its outcome reports
source-health-unavailable when the health requirement cannot be established.
A newer report outside the watched area counts as departure, not silence.
Currently this evidence is available for installed poll sources bound to their exact managed table and workspace environment. It expires two configured poll periods after the successful poll started. Changing the source configuration requires a successful poll of that configuration before it qualifies again. Other source kinds report unknown until they implement equivalent evidence. This measures ingestion activity, not a provider guarantee that every real-world observation was delivered. Choose source cadence and absence windows accordingly.
Published app components display the same governed health state independently of track timestamps and connection state. Animation does not make an old observation fresh. Source monitoring runs independently of open applications.
Entity report gaps without mission roles
Use kind: 'report_gap' for a vessel feed that has no mission-role directory:
{
slug: 'north-sea-vessel-report-gap',
displayName: 'Vessel stopped reporting',
kind: 'report_gap',
target: {
objectTypeSlug: 'Track',
entityProperty: 'trackId',
timeProperty: 'observedAt',
latProperty: 'latitude',
lngProperty: 'longitude',
},
area: { objectTypeSlug: 'Zone', areaId: 'north-sea' },
windowSeconds: 900,
absence: { baselineSeconds: 3600, minMissingEntities: 1, requireSourceHealth: true },
suppression: {
cooldownSeconds: 3600,
refireOnNewRole: false,
refireOnScoreDelta: 1,
},
emit: {
objectTypeSlug: 'Detection',
clearanceCeiling: { schemeId: 'default', rank: 2 },
},
}The baseline period precedes the missing-report window; baselineSeconds must
exceed windowSeconds. A later report anywhere means that the entity is still
reporting, even if it left the watched area. The detection carries the last
known position, source marking, and entity ID without an invented role or
weight. refireOnScoreDelta compares the number of missing entities. Set
refireOnNewRole to false, because this rule has no roles. The evaluator
requires a complete source read; an operational MIK rule should also require
fresh source-health evidence. A correlated-track stream currently reports
health as unknown until its source-runtime completion evidence is connected,
so this rule will not assert a missing vessel from that stream yet.
Restricted-zone episodes
Use kind: 'zone' to monitor current positions without mission-role weights:
{
slug: 'restricted-north-sea',
displayName: 'Restricted North Sea area',
kind: 'zone',
target: {
objectTypeSlug: 'Track',
entityProperty: 'vesselId',
observationProperty: 'observationId',
timeProperty: 'observedAt',
latProperty: 'latitude',
lngProperty: 'longitude',
},
area: { objectTypeSlug: 'Zone', areaId: 'restricted-north-sea' },
windowSeconds: 120,
zone: { maxTransitionGapSeconds: 60, requireSourceHealth: true },
emit: {
objectTypeSlug: 'Detection',
clearanceCeiling: { schemeId: 'default', rank: 2 },
},
}Declare the referenced object types and governed source bindings in your ontology.
The area object must expose areaId, minLatitude, maxLatitude, minLongitude,
and maxLongitude. By default the watch evaluates that rectangle. To evaluate a
drawn zone exactly, set area.boundaryProperty to an area property that holds a
GeoJSON Polygon or MultiPolygon as text (at most 1,024 positions and 16 KiB;
holes are supported):
area: { objectTypeSlug: 'Zone', areaId: 'restricted-north-sea', boundaryProperty: 'boundary' },A position is then inside when it is inside the rectangle and inside the polygon;
a position on an edge counts as inside. The polygon must lie within the rectangle,
which remains the read filter. Bounds stored as single-precision float are
accepted: a vertex past a bound by no more than that rounding is inside, and the
rectangle is widened to it. A missing, invalid, or overflowing polygon stops the
watch with area-boundary-missing, area-boundary-invalid, or
area-boundary-outside-box rather than falling back to the rectangle. Split a zone
that crosses the 180° meridian. boundaryProperty applies to every watch kind.
Each position needs a stable entity and observation identifier, valid coordinates, and an event timestamp. Keep positions outside the area in the target source so exits can be observed.
The first eligible inside observation produces presence. An entry requires
a recent outside predecessor within maxTransitionGapSeconds. Repeated inside
positions do not create repeated alerts. A confirmed exit allows another episode.
Stale, future, duplicate, and out-of-order observations cannot create a transition.
Changing the area bounds or redrawing its polygon cannot manufacture an entry.
If a higher priority source corrects the accepted position at the same event time,
the watch updates its episode. A correction from outside to inside is presence,
because equal timestamps do not prove movement across the boundary. Further
corrections at that timestamp do not create another alert.
To also report loiter, add zone.dwell together with target.speedProperty, the
target property holding speed over ground in knots:
target: { /* as above */ speedProperty: 'speed' },
zone: { maxTransitionGapSeconds: 300, dwell: { minSeconds: 900, maxSpeedKnots: 2 } },A loiter detection fires once an entity has stayed inside at or below maxSpeedKnots
for minSeconds, measured from the first slow inside position. A faster position, a
position without a speed, an exit, a gap between positions longer than
maxTransitionGapSeconds, or a redrawn area ends that slow run; the next slow run can
loiter again. One slow run produces at most one loiter detection. Declaring either
dwell or speedProperty without the other is rejected.
Set requireSourceHealth: true for operational feeds. Without fresh complete
source-health evidence, the watch waits instead of treating missing data as a
confirmed movement. Configure the source's health reporting before enabling it.
Zone rules omit signature, threshold, suppression, and target.roleVia.
Their detection type should expose kind (presence, entry, or loiter), watchRevisionId,
entityId, observationId, targetObjectType, and evidence, alongside the shared
detectionId, watchSlug, areaId, firedAt, windowSeconds, clearanceCeiling,
and contributorsMarking fields. Evidence includes one position for presence, the
ordered outside/inside pair for entry, or the first and latest slow positions for loiter. The optional dispatch configuration
works as for other watches. Evaluation and retry recovery run independently of
open app viewers.