Ontologies
Author Ontology packs — typed Object Types, Link Types, Action Types, identity rules, and bindings
This artifact ships inside a Pack. For the shared lifecycle — install, pack build, upload — see Packs & Authoring SDK.
An Ontology is the type system your tenant runs on: Customer, Asset, Risk, Incident, the relationships between them (owns, affects, mitigates), and the typed actions you can take against them (AssignAsset, AssessRisk). The Authoring SDK lets you ship that as a versioned, validated Ontology pack.
An ontology is the type system. A knowledge base is the content that uses it. They are different things — one ontology, many knowledge bases. Multiple ontologies can run side-by-side in one tenant (default + per-domain + pack-imported).
Install
bun add -d @scrydon/sdk-authoring zodnpm install --save-dev @scrydon/sdk-authoring zodimport {
defineOntology,
defineObjectType,
defineLinkType,
defineActionType,
defineIdentityRule,
defineBinding,
defineProperty,
} from '@scrydon/sdk-authoring/ontologies'What lives in a pack
| Element | Purpose |
|---|---|
| Object Type | A typed entity — Customer, Asset, RiskFinding. Has properties, a kind (analytical / transactional / binding_only). |
| Link Type | A typed relationship — affects(RiskFinding → Customer). Cardinality + optional reverse traversal name. |
| Action Type | A server-side mutation gated by OPA. Carries a Zod input schema and a side-effect descriptor (object_edit / link_edit / outbox / webhook). |
| Identity Rule | How instances are de-duplicated. Deterministic match keys, optional probabilistic ER (signals + thresholds). |
| Binding | Bridges an upstream source (silver_table, memex_page, process_flow_task, process_flow_action, extraction, stream, manual) into typed Object or Link instances. |
A complete example
import {
defineOntology,
defineObjectType,
defineLinkType,
defineActionType,
defineIdentityRule,
defineProperty,
} from '@scrydon/sdk-authoring/ontologies'
import { z } from 'zod'
export default defineOntology({
id: 'acme-crm',
version: '1.0.0',
displayName: 'ACME CRM',
description: 'Salesforce-style CRM ontology',
maturity: 'preview',
author: { name: 'Acme Inc.' },
objectTypes: [
defineObjectType({
slug: 'Account',
displayName: 'Account',
kind: 'transactional',
properties: [
defineProperty({
slug: 'name',
displayName: 'Legal name',
scalarType: 'string',
cardinality: 'required',
}),
defineProperty({
slug: 'jurisdiction',
displayName: 'Jurisdiction',
scalarType: 'string',
classification: 'internal',
}),
defineProperty({
slug: 'riskTier',
displayName: 'Risk tier',
scalarType: 'string',
piiFlags: ['regulatory'],
}),
],
}),
defineObjectType({
slug: 'Asset',
displayName: 'Asset',
kind: 'transactional',
properties: [
defineProperty({ slug: 'name', displayName: 'Name', scalarType: 'string' }),
defineProperty({ slug: 'value', displayName: 'Value', scalarType: 'float' }),
],
}),
],
linkTypes: [
defineLinkType({
slug: 'owns',
displayName: 'Owns',
sourceTypes: ['Account'],
targetTypes: ['Asset'],
cardinality: '1-N',
reverseName: 'ownedBy',
}),
],
actionTypes: [
defineActionType({
slug: 'AssignAsset',
displayName: 'Assign asset to account',
subjectTypeSlug: 'Asset',
inputZod: z.object({ accountId: z.string().uuid() }),
sideEffectDescriptor: { kind: 'link_edit' },
}),
],
identityRules: [
defineIdentityRule({
objectTypeSlug: 'Account',
deterministicKeys: ['name', 'jurisdiction'],
}),
],
bindings: [],
})Property model
| Field | Notes |
|---|---|
scalarType | One of string, int, float, bool, uuid, timestamp, date, json, bytes |
cardinality | required, optional, or many |
classification | public, internal, confidential, restricted — flows into DLP and RBAC |
piiFlags | Free-form tags surfaced to DLP redaction at retrieval time |
Object Type kinds
analytical— bulk facts, query-heavy. Backed by asilver_tablebinding.transactional— editable entities (manual entry, process-flow output). Postgres-native.binding_only— read-through projection only (e.g.memex_page,extraction). Never directly edited.
Bindings — the seven kinds
| Kind | Bridges |
|---|---|
silver_table | A bronze/silver analytics table → an Object Type |
memex_page | A Memex KB content node (slug/alias match) → an Object instance |
process_flow_task | A process_instance_task row → a typed Object instance |
process_flow_action | A process_instance_task_action of actionType=entity_link → a typed Link instance |
extraction | The 5-WF analytics extraction pipeline → an Object instance |
stream | A streaming source (Kafka, webhook fanout) → an Object instance |
manual | Direct workbench entry → an Object instance |
defineBinding({
objectTypeSlug: 'Account',
kind: 'silver_table',
source: { table: 'silver_account' },
identitySpec: { keys: ['external_id'] },
columnMap: { name: 'legal_name', jurisdiction: 'country_code' },
})Entity collapse — identitySpec.entityColumns
Observation-heavy sources (position tracks, sensor readings, log events) often
carry millions of rows for a few thousand real-world things. Declaring
entityColumns — a strict subset of identityColumns naming the
persistent entity being observed — tells the graph to render one node per
entity with an exact observation count, instead of one node per row:
defineBinding({
objectTypeSlug: 'AircraftPosition',
kind: 'silver_table',
source: { table: 'adsb_positions' },
identitySpec: {
identityColumns: ['icao24', 'seenAt'], // one observation (position fix)
entityColumns: ['icao24'], // the persistent thing (airframe)
},
columnMap: { icao24: 'icao24', seenAt: 'seenAt', latitude: 'lat', longitude: 'lon' },
})Effects on the graph page:
- One node per aircraft, labeled with an exact
N observationscount computed at read time under your permissions — never a sample presented as the data. - Relationship joins on entity-level columns (e.g.
icao24) connect at the entity level. - Every graph response reports
shown / totalper type; anything the readability budget hides is represented by an aggregate node carrying the exact remainder count. - The overview renders the connected fabric: instances that participate in at least one relationship. Instances with no computed relationship are not scattered across the canvas as isolated dots — they are folded into their type's aggregate node (with the exact count) and remain browsable through type expansion and search. An ontology with no computable relationships at all falls back to a per-type sample so the page stays browsable.
- Aggregate nodes are click-to-expand: each click pages a bounded set of that type's members onto the canvas (tethered around the aggregate) and the remaining count decrements exactly; when nothing remains the aggregate disappears.
- Expand neighbors shows what is connected first — neighbors grouped by relationship and target type with counts — so you add the groups you care about instead of flooding the layout.
- The Map view (toggle at the bottom of the graph page) renders coordinate-bearing types as map cells carrying exact in-view counts under your permissions. Zooming subdivides cells; when a type's in-view total is small enough, the real instances render instead. Truncated cell lists are labeled as lower bounds — never presented as final numbers.
- Relationships render on the Map view too: reference link types with an explicit join rule draw arcs between cells, each carrying the exact number of linked pairs between those two areas (hover to see it); proximity links draw thin connectors among the instances currently shown, and the view states that they cover the shown instances only — zoom in to materialize the rest. Each link type reports its in-view pair total alongside the type totals.
Validation: entityColumns must be a non-empty strict subset of
identityColumns (equal sets are rejected — omit entityColumns instead).
Pack install rejects manifests that violate this.
Graph visualization
Dense packs can include optional graph visualization hints. These hints do not change the ontology schema; they tell graph surfaces how to render an instance view efficiently.
Use mode: 'compact' when a pack has one high-value root Object Type and many high-cardinality child bindings. The graph endpoint projects only the root binding, renders aggregate nodes per root instance, and skips row projection for collapsed Object and Link bindings.
defineOntology({
id: 'acme-docs',
version: '1.0.0',
displayName: 'ACME Documents',
description: 'Document intelligence ontology',
maturity: 'preview',
author: { name: 'Acme Inc.' },
visualization: {
graph: {
mode: 'compact',
rootObjectType: 'DocumentCorpus',
rootLimit: 100,
rootGroup: {
slug: 'DocumentCorpusIndex',
label: 'All document corpora',
edgeLabel: 'contains corpus',
},
collapsedObjectTypes: ['Document', 'DocumentSection', 'ExtractedEntity'],
collapsedLinkTypes: ['ContainsDocument', 'MentionsEntity'],
aggregates: [
{
slug: 'Document',
label: 'Documents',
linkSlug: 'ContainsDocument',
edgeLabel: 'contains documents',
countObjectTypes: ['Document'],
},
{
slug: 'ExtractedEntity',
label: 'Extracted entities',
linkSlug: 'MentionsEntity',
edgeLabel: 'mentions entities',
countObjectTypes: ['ExtractedEntity'],
countLinkTypes: ['MentionsEntity'],
},
],
},
},
objectTypes: [],
linkTypes: [],
actionTypes: [],
identityRules: [],
bindings: [],
})Identity Rules — deterministic + probabilistic
Identity rules tell the platform how to merge instances. The deterministic path is required; the probabilistic path is opt-in for fuzzier sources.
defineIdentityRule({
objectTypeSlug: 'Account',
deterministicKeys: ['external_id', 'name + jurisdiction'],
probabilistic: {
blockingKeys: ['name'],
signals: [
{ name: 'name_levenshtein', weight: 0.6, on: 'name' },
{ name: 'jurisdiction_match', weight: 0.4, on: 'jurisdiction' },
],
thresholds: { auto: 0.85, review: 0.70 },
},
})Action Types — server-side, OPA-gated mutations
Action Types are the only typed write path. Every action runs the same way:
- Zod-validate the input against
inputZod. - OPA evaluates
policyRegistry.evaluate({ resource_type: 'action', action: <slug>, ... }). - The side-effect descriptor decides what executes — direct object/link edit, an outbox dispatch, or a webhook.
- Audit row is written with
bundleHash+policy_execution_grant.grantId.
sideEffectDescriptor.kind | What happens |
|---|---|
object_edit | Upsert into the Object Type's transactional store |
link_edit | Upsert into the Link Type's edge store |
outbox | Enqueue a transactional outbox event |
webhook | Fire a configured webhook (URL in config) |
Maturity
Pick one — surfaced as a badge in the workbench:
verified— production-ready, owned and maintainedpreview— feature-complete, may evolveexperimental— early access, expect breaking changes
Validation
The pack is validated against OntologyManifestSchema (Zod) at build time and again on the server at upload time. Common failures:
| Error | Cause |
|---|---|
id must be lowercase kebab-case | Pack id must match /^[a-z][a-z0-9-]*$/ |
Slug must start with a letter, alnum + _ - | Object/Link/Action/Property slug failed regex |
Must be a valid semver string | version is not semver |
linkType source/target slug not found | sourceTypes / targetTypes reference an unknown Object Type slug |
Pack-as-integration-bundle (today)
Ontology packs ship inside an integration bundle via definePackOntology — the registry layer dispatches packs the same way it dispatches vendors. A standalone scrydon-ontologies CLI is on the roadmap; until then, follow the Integrations build flow with a top-level definePackOntology(...) export.
import { definePackOntology } from '@scrydon/sdk-authoring/integrations/define'
import myCrmOntology from './crm.ontology'
export default definePackOntology({
id: 'acme-crm',
version: '1.0.0',
displayName: 'ACME CRM',
description: 'Salesforce-style CRM ontology',
maturity: 'preview',
author: { name: 'Acme Inc.' },
objectTypes: myCrmOntology.objectTypes,
linkTypes: myCrmOntology.linkTypes,
actionTypes: myCrmOntology.actionTypes,
bindings: myCrmOntology.bindings,
identityRules: myCrmOntology.identityRules,
})