Risk rules
Declare an explained, context-dependent risk score for the objects of one type — a closed set of deterministic reasons, each citing the objects that made it hold.
Risk rules are authored inside an Ontology extension alongside your Object Types, Link Types, Bindings and watch rules. This capability is provisional: the rule shape may change before it is declared stable.
A risk rule scores every object of a target type — a vessel, a company, a facility — and says why. Each reason that holds adds its declared weight and cites the objects that made it hold, so the score is exactly the sum of its listed reasons. There is no model in the loop: the same facts always give the same assessment.
Each assessment is written to the ontology as a derived object of a type you declare. It is read like any other object, and a reader who is not cleared for every fact that fed it is served nothing — not an error.
Declaring a risk rule
Add a riskRules array to your defineOntology call:
import { defineOntology } from '@scrydon/sdk-authoring/ontologies'
export default defineOntology({
id: 'maritime-picture',
version: '1.0.0',
displayName: 'Maritime picture',
// ... object types (Vessel, PortCall, RiskAssessment, ...), link types, bindings, watch rules ...
riskRules: [
{
slug: 'vessel-risk',
displayName: 'Vessel risk',
version: '1.0.0',
target: { objectTypeSlug: 'Vessel' },
reasons: [
{
id: 'flag-hopping',
label: 'Flag changed twice in 12 months',
weight: 20,
evaluator: {
kind: 'propertyChanges',
viaLinkTypeSlug: 'SourceRecordOfVessel',
property: 'flag',
timeProperty: 'sourceAsOf',
windowSeconds: 31_536_000,
minChanges: 2,
},
},
{
id: 'sanctioned-port-call',
label: 'Port call at a sanctioned terminal',
weight: 30,
evaluator: {
kind: 'linkedPropertyIn',
viaLinkTypeSlug: 'PortCallOfVessel',
property: 'portLocode',
values: ['IRBND'],
where: { basis: 'observed' },
},
},
{
id: 'port-call-mismatch',
label: 'Declared port calls disagree with what was observed',
weight: 15,
evaluator: {
kind: 'declaredObservedMismatch',
viaLinkTypeSlug: 'PortCallOfVessel',
basisProperty: 'basis',
declaredValue: 'declared',
observedValue: 'observed',
alignProperty: 'sequence',
compareProperty: 'portLocode',
timeProperty: 'arrivedAt',
},
},
{
id: 'ais-gap',
label: 'AIS gap',
weight: 25,
evaluator: {
kind: 'detectionWithin',
objectTypeSlug: 'DarkVesselDetection',
timeProperty: 'firedAt',
windowSeconds: 86_400,
evidenceProperty: 'evidence',
evidenceKey: 'entityId',
subjectProperty: 'mmsi',
},
},
],
// The same vessel scores higher inside a zone of interest.
context: {
area: { objectTypeSlug: 'ZoneOfInterest', areaIds: ['be-eez'] },
position: { latProperty: 'lastLatitude', lngProperty: 'lastLongitude' },
multiplier: 1.5,
},
// Which sources may feed the score (a filter, see below).
sources: { property: 'sourceSystem', allow: ['LLOYDS', 'GISIS', 'EMSA'], unattributed: 'include' },
freshness: { maxAgeSeconds: 604_800 },
bands: [
{ name: 'low', minScore: 0 },
{ name: 'elevated', minScore: 30 },
{ name: 'high', minScore: 60 },
],
emit: {
objectTypeSlug: 'RiskAssessment',
clearanceCeiling: { schemeId: 'default', rank: 2 },
},
},
],
})Field reference
| Field | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Stable identifier, used as the name of the emitted SCHEMA_RISK_RULE declaration and recorded on every assessment. |
displayName | string (1–100 chars) | Yes | Human-readable name. |
description | string (≤500 chars) | No | Longer description. |
enabled | boolean | No | Defaults to true. A disabled rule is reported and not evaluated. |
version | string | Yes | Your version of the rule, stamped on every assessment as ruleVersion. |
target.objectTypeSlug | string | Yes | The type whose objects are assessed. |
reasons | array (1–32) | Yes | The reasons, each with a unique kebab-case id, a label, a positive weight and one evaluator. |
context | object | No | A multiplier (≥ 1) applied while the object is inside one of the listed areas of interest. |
sources | object | Yes | The source allowlist. |
freshness.maxAgeSeconds | number | Yes | Evidence older than this lowers the assessment's confidence. A linked position older than this does not place the target. |
bands | array (1–8) | Yes | Named score bands. The first starts at 0; thresholds rise strictly. |
emit.objectTypeSlug | string | Yes | The object type each assessment is written as. Declare it in the same ontology. |
emit.clearanceCeiling | { schemeId, rank } | Yes | The highest marking the rule may read. Required — a rule aggregates, so it fails closed rather than reading at whatever the caller can see. |
Every object type and link type a rule names must be declared in the same ontology, and every link a reason reads through must touch the target type. A rule that names something missing fails to install.
Evaluators
The evaluator set is closed. Each one reads the target's linked objects (viaLinkTypeSlug) or the detections of a type, and holds when its condition is met.
A link type declared with a join and no link binding is read through its join: the linked objects are those of the other type whose join column equals the target's. A join with normalize or a "*" endpoint cannot be read this way, so a target that needs one is reported incomplete-read rather than scored.
kind | Holds when | Cites |
|---|---|---|
propertyChanges | property took at least minChanges new values inside windowSeconds, ordering the linked records by timeProperty. Use minChanges: 1 for "changed recently". | The records on both sides of each change. |
linkedPropertyIn | A linked object's property is one of values, after the optional where equalities and the optional timeProperty + windowSeconds recency bound (declared together). | The matching objects. |
declaredObservedMismatch | Linked objects whose basisProperty is declaredValue disagree with those that are observedValue, aligned on alignProperty and compared on compareProperty. A declared entry with no observed one, or the reverse, is a mismatch. Objects with no alignment value are ignored. | Both sides of every mismatch. |
detectionWithin | A detection of objectTypeSlug fired inside windowSeconds (by timeProperty) and its evidenceProperty list names the target: an element whose evidenceKey equals the target's subjectProperty. This is the shape a watch rule detection has. | The matching detections. |
Each reason cites at most 16 objects. Every fact the rule read still counts toward the assessment's marking, cited or not.
How linked objects are read
A reason with a window (propertyChanges, or linkedPropertyIn with timeProperty + windowSeconds) reads only the linked objects its timeProperty dates inside windowSeconds, so a target with a long history — a vessel with thousands of AIS fixes — is read one window at a time. Linked objects outside every window do not feed the assessment or its marking. One linked read returns at most 500 objects, and one detection read at most 200 per detection type. When a read stops at that bound, the target is still assessed if the objects read decide the reason:
kind | Decided by a read that stopped at its bound when |
|---|---|
linkedPropertyIn, detectionWithin | It found a match. Otherwise the target is incomplete-read. |
propertyChanges | The changes between objects inside the window already reach minChanges. Otherwise incomplete-read. A complete window one change short of minChanges is read again without the window, because the record just before the window can add that change. |
declaredObservedMismatch | Never: every linked object is needed, so more than 500 is incomplete-read. |
A window is applied by the read only for a link declared with a join and no link binding. A link with a link binding is read whole, and the same rule decides when more than 500 objects stop it.
Context
context.position places the target either by its own latProperty / lngProperty, or — with viaLinkTypeSlug and timeProperty — by the latest linked object no older than freshness.maxAgeSeconds. Only that latest object is read: the evaluation looks back from now, doubling from one minute, so it never reads a long history to find it. The areas are rows of context.area.objectTypeSlug carrying an areaId and a minLatitude / maxLatitude / minLongitude / maxLongitude box, the same row shape a watch rule reads. When the target is inside one of them, every reason's contribution is multiplied by multiplier.
Source allowlist
sources decides which facts may feed the score, by the value of sources.property on each fact. A fact from a source not in allow is left out of the score and of the marking. unattributed says what happens to a fact that names no source at all (a detection, an area row): include or exclude.
The allowlist is a filter on what this rule reads, not an enforced usage constraint. A fact it excludes is still readable through every other governed path. Do not rely on it to keep a source's data away from a reader.
The assessment
Each assessment is an object of the emit type, one per (rule, target object). A new assessment supersedes the previous one for the same target, so there is always exactly one current assessment.
| Property | Description |
|---|---|
score | The sum of reasons[].contribution. |
band | The highest band whose minScore the score reaches. |
reasons[] | { reasonId, label, contribution, fresh, evidence[] } for each reason that held. contribution is weight × multiplier. Each evidence item is { objectType, objectId, marking, observedAt }. |
context | { applied, multiplier, areaIds, evidence }, or null when the rule declares no context. |
confidence | The share of the reasons given whose evidence is all dated and within freshness.maxAgeSeconds. Evidence with no date counts as not fresh. With no reasons it is 1. |
ruleName, ruleVersion, evaluatedAt, subjectType, subjectId | What was assessed, by which rule, when. |
Marking. An assessment is marked at the join of every fact that fed it and of the rule itself, never above emit.clearanceCeiling, and never below the assessment it replaces. A reader cleared for all of that reads it; anyone else is served no object.
Running an evaluation
Assessments are produced when an evaluation runs, and only then: risk.assess cannot be submitted through actions.submit, so every assessment is one an evaluation derived.
The platform runs an evaluation for every workspace environment on the same schedule as watch rules, right after that environment's watches, so a detectionWithin reason sees the detections that run produced. It runs as the environment's watch execution account, with the same outcomes: with no account selected, only rules whose ceiling is rank 0 are evaluated; with an account, a rule whose ceiling is above the account's clearance rank is skipped; with an account that no longer qualifies, no rule is evaluated and none falls back to the platform identity. A rule skipped for clearance is listed with its reason in the outcomes of an on-demand evaluation; the scheduled run does not log it.
To evaluate on demand, call it from the ontology SDK:
const result = await ontology.riskAssessments.evaluate({ ontology: 'maritime-picture' })
// { evaluated, assessed, degradedReads, outcomes: [{ ruleName, objectId, assessed, reason?, assessmentId?, marking? }] }The evaluation reads at each rule's emit.clearanceCeiling and skips a rule whose ceiling your clearance does not reach. An assessment that would be identical to the current one is not rewritten (reason: "unchanged"). A target whose facts could not be read completely is not assessed (reason: "incomplete-read") rather than scored low.