Metamodel Reference

August 14, 2026 · View on GitHub

The metamodel defines your project's entity types, properties, and relations. It's stored in schema.yaml at your project root.

Renamed from metamodel.yaml. Projects created before the rename still work: rela reads metamodel.yaml when no schema.yaml is present, and warns once at startup. Run rela migrate to rename the file. The legacy name will keep working until a future major version. If both files exist, schema.yaml is used and the metamodel.yaml is ignored — rela migrate reports it so you can merge and delete it.

Structure

version: "1.0"
namespace: "https://example.org/ontology/architecture#"
description: |
  Optional end-user prose describing what this deployment is for. Documentation
  only — surfaced by generated docs; ignored by validation and the write path.

types:
  # Custom enum types

entities:
  # Entity type definitions

relations:
  # Relation definitions

Including Partial Metamodels

For larger projects, you can split your metamodel across multiple files using the includes: key. This keeps each domain's definitions in a focused, manageable file.

Syntax

# schema.yaml
version: "1.0"
namespace: "https://example.org/ontology/architecture#"

includes:
  - compliance/controls.yaml
  - risk.yaml

types:
  status:
    values: [draft, proposed, accepted, deprecated]
    default: draft

entities:
  requirement:
    label: Requirement
    id_prefix: REQ-
    properties:
      title:
        type: string
        required: true

The includes: key is always a YAML list of file paths, resolved relative to the project root (where schema.yaml lives).

Included File Format

Each included file is a partial metamodel. It can contain any combination of types:, entities:, relations:, and validations: — but must not contain version:, namespace:, or description: (these are deployment-wide, allowed only in the root schema.yaml).

# compliance/controls.yaml
types:
  applicability:
    values: [applicable, not_applicable, partial]

entities:
  control:
    label: Control
    id_prefix: CTL-
    properties:
      title:
        type: string
        required: true
      applicability:
        type: applicability

relations:
  implements_control:
    label: implements
    from: [requirement]
    to: [control]
    inverse: implementedBy

validations:
  - name: controls-need-applicability
    description: "Controls must have applicability set"
    entity_type: control
    then:
      - "applicability!="
    severity: warning

Nested Includes

Included files can themselves include other files:

# compliance/controls.yaml
includes:
  - shared/audit-types.yaml

entities:
  control:
    # ...

Circular includes are detected and produce a clear error:

circular include detected: schema.yaml → compliance/controls.yaml → shared/audit-types.yaml → compliance/controls.yaml

Diamond Includes

If the same file is reachable from multiple include paths (a "diamond" pattern), it is loaded only once. This is not an error.

# schema.yaml
includes:
  - a.yaml # includes shared.yaml
  - b.yaml # also includes shared.yaml — loaded once, no conflict

Conflict Handling

If the same type, entity, relation, or validation name is defined in more than one file, loading fails with an error identifying both files:

duplicate entity "control": defined in both compliance/controls.yaml and risk.yaml

To resolve conflicts, rename one of the definitions or move it to a shared file.

Error Messages

SituationError
Duplicate definitionduplicate entity "control": defined in both a.yaml and b.yaml
Circular includecircular include detected: a.yaml → b.yaml → a.yaml
File not foundinclude file not found: missing.yaml (included from schema.yaml)
Root-only fieldincluded file a.yaml must not contain "version" (only allowed in root schema.yaml)

Custom Types

Define reusable types that can be used in entity properties. Custom types support enum values, regex validations, or both.

Enum Types

Define allowed values for a property:

types:
  status:
    values: [draft, proposed, accepted, deprecated, rejected, retired]
    default: draft

  priority:
    values: [critical, high, medium, low]

Display Labels

Enum values are stored as-is (typically snake_case identifiers). For a friendlier data-entry UI you can attach an optional human-readable label to any value with a labels: map keyed by value:

types:
  status:
    values: [draft, in_progress, wont_fix]
    labels:
      in_progress: In Progress
      wont_fix: Won't Fix

Labels also work on inline enums:

properties:
  status:
    type: enum
    values: [open, in_progress, closed]
    labels:
      in_progress: In Progress

Notes:

  • Labels are display-only. The stored value, the value submitted by forms, validation, and badge colours all key on the raw value — only the text shown in the data-entry UI changes. A value with no entry in labels renders raw.
  • Labels are surfaced in the data-entry web UI (select dropdowns, badges across lists / detail / kanban, and filter menus). The CLI and the OpenAPI enum output stay value-based.
  • labels is optional and backwards compatible: existing metamodels with plain value lists are unchanged and need no migration.
  • When a property references a custom type, labels come from the custom type; an inline labels map on such a property is ignored (mirroring how an inline values list is ignored there).

Value Descriptions

Where labels: gives a value its short display text, descriptions: gives the longer prose meaning of a value — what it signifies. It is documentation only (surfaced by generated docs), keyed by value like labels::

types:
  ticket-status:
    values: [open, in_progress, closed]
    labels:
      in_progress: In progress
    descriptions:
      open: A newly filed ticket that no one has started yet.
      in_progress: Someone is actively working on the ticket.
      closed: The ticket is finished; no further work is expected.
  • descriptions is optional and independent of labels: a value may have either, both, or neither. It never affects storage, validation, or forms.
  • Distinct from the type-level description: scalar (which documents the type as a whole); descriptions: documents each individual value.

State Machines (transitions)

An enum custom type becomes a state machine when it declares transitions: — the legal value→value moves. Instead of any value changing to any other, only the declared edges are allowed; the write path rejects an undeclared move (422), and each edge can additionally require an ACL permission (guard, 403) and/or a data precondition (when, 422).

types:
  ticket-status:
    values: [todo, doing, review, done]
    initial: todo # the only value a newly created entity may enter at
    transitions:
      - from: todo
        to: doing
        label: Start progress # optional: names the MOVE (an action verb)
        help: Move here once someone picks up the ticket. # optional: why/when
      - from: doing
        to: review
        label: Send to review
      - from: review
        to: done
        guard: close # requires the `close` ACL permission
        when: 'count_relations(entity, "reviewed-by") > 0' # precondition
      - from: review
        to: doing
        label: Reopen
FieldMeaning
from / toSource and target values; both must be declared in values.
initialThe only value a create may set (else default). New entities are pinned to it — a create cannot enter a guarded mid-lifecycle state.
guardAn ACL permission the acting principal must hold for the move. Enforced on served paths; inert on a direct CLI write with no policy.
whenA predicate (same language as validations, evaluated against the entity + graph) that must hold for the move.
labelOptional display text for the move (the action, e.g. "Start progress"), used by the data-entry status control. Display-only — the stored value is still to. Absent → the UI falls back to the target value's display label, then the raw value.
helpOptional longer prose explaining why or when a user would make this move, beyond the short label. Documentation only — surfaced by generated docs, ignored by enforcement.

The data-entry UI reads these to render a status control that offers only the moves the current user can perform right now (see the _transitions affordance in the data-entry API reference). transitions is optional and backwards compatible: an enum type without it keeps the historical "any value may change to any other" behavior.

Regex Validations

Define validation patterns with user-friendly error messages. Multiple patterns can be combined—all must pass for a value to be valid:

types:
  semver:
    description: "Semantic version number"
    validations:
      - pattern: '^\d+\.\d+\.\d+$'
        error: "Must be valid semver (e.g., 1.2.3)"

  rrule:
    description: "iCal recurrence rule (RFC 5545)"
    validations:
      - pattern: "^FREQ=(YEARLY|MONTHLY|WEEKLY|DAILY)"
        error: "Must start with valid FREQ"
      - pattern: "^(?!.*COUNT=.*UNTIL=)"
        error: "Cannot specify both COUNT and UNTIL"

  email:
    validations:
      - pattern: "^[^@]+@[^@]+\\.[^@]+$"
        error: "Must be a valid email address"

Each validation requires:

FieldDescription
patternRegex pattern that values must match
errorUser-friendly error message shown when validation fails

Benefits of multiple simple patterns vs one complex regex:

  • Each pattern has its own clear error message
  • Users see exactly which validation failed
  • Patterns are easier to write and maintain
  • No mega-regex with opaque errors

Empty Values

  • Enum types: Empty string is not a valid value (fails validation)
  • Regex-only types: Empty strings skip validation (let required handle it)
  • List properties: Each item in the list is validated independently

Reserved Type Names

The following names are reserved for built-in property types and cannot be used as custom type names:

  • string - Free-form text
  • date - Date values
  • datetime - Time-bearing instants (date + time)
  • integer - Whole numbers
  • boolean - True/false values
  • enum - Inline enumeration (use values: directly in property definition)

Attempting to define a custom type with a reserved name will produce an error:

cannot define custom type "string": name is reserved for built-in type

Entity Types

Each entity type defines:

FieldDescription
labelDisplay name
label_pluralPlural display name (defaults to label + "s")
descriptionDocumentation explaining intent and usage (optional)
aliasesAlternative names for CLI (e.g., req for requirement)
id_typeshort (default), sequential, or manual - controls ID generation
id_prefixSingle ID prefix (e.g., REQ-)
id_prefixesMultiple ID prefixes (e.g., ["DEC-", "ADR-"])
propertiesProperty definitions
default_sortDefault sort order for list views
colorFill color for graph visualizations (hex or named)
border_colorBorder color for graph visualizations
display_propertyProperty whose value names the entity. See Display name below.

Display name

Every entity type has a primary property — the property whose value is the entity's display name. When unset, rela picks one automatically: it checks title, name, label in that order (when each is a required string property), then falls back to any required string property (alphabetical), then to the entity ID. That works for English schemas but is brittle for non-English ones — the priority list never matches Dutch naam or titel, so the fallback runs, and the choice silently flips if a second required string property is added later.

Set display_property explicitly to make the choice load-bearing:

entities:
  applicatie:
    label: Applicatie
    display_property: naam
    properties:
      naam:
        type: string
        required: true

Allowed types. The named property must be string, integer, boolean, or enum (custom enum-like types are accepted). date, datetime, file, rrule, and list-typed (list: true) properties are rejected at metamodel-load time — their default rendering produces strings nobody designed as a display name (e.g. "2026-04-25 00:00:00 +0000 UTC", "[a b c]").

Runtime behavior. Non-string values (integers, booleans, enum values) are stringified via fmt.Sprintf("%v", val). The display falls back to the entity ID when the value is empty, missing, or nil.

Templates. When display_property contains a {, it is a template: each {propname} placeholder is replaced with that property's value, and literal text (spaces, commas) passes through. This composes a display name from several fields:

entities:
  persoon:
    label: Persoon
    display_property: "{voornaam} {tussenvoegsel} {achternaam}"
    properties:
      voornaam: { type: string }
      tussenvoegsel: { type: string }
      achternaam: { type: string, required: true }

renders "Jeroen Vloothuis" — and "Jan van der Berg" for someone with a tussenvoegsel. Consecutive whitespace collapses to one space and the result is trimmed, so an empty middle field doesn't leave a double space. The ID fallback applies only when the rendered result is empty after trimming — a template with literal text (e.g. "Mr. {achternaam}") always renders that text, so it never falls back to the ID even when every placeholder is empty. Each placeholder must name a defined property of an allowed type (same rules as above), checked at load. A template names no single primary property, so it is display-only — it isn't a target for writing values.

Validation. A typo, whitespace mistake, list-typed reference, disallowed type, or a malformed template (unclosed {, empty {}, or a placeholder naming an undefined property) fails metamodel-load with a diagnostic naming the entity, the offending value, and the available properties.

How the data-entry app surfaces the display name across lists, cards, breadcrumbs, and related-entity links is documented in GUIDE-data-entry.md → Display names.

ID Types

Entity IDs can be auto-generated or manually specified:

TypeDescriptionExample IDs
shortRandom base36 IDs (default)REQ-a3f8, REQ-k2m9
sequentialAuto-incremented numeric IDsREQ-001, REQ-002, DEC-003
manualManually specified string IDsauth-module, user-service

Short IDs (default):

  • Automatically generated random base36 strings
  • Format: PREFIX-XXXX (e.g., REQ-a3f8)
  • Compact and collision-resistant
  • Excluded from gap analysis (no sequence to track)

Sequential IDs:

  • Auto-incremented numeric suffix
  • Format: PREFIX-NNN (e.g., REQ-001)
  • Gap analysis detects missing numbers in sequences

Manual IDs:

  • Require --id flag when creating entities
  • No automatic generation
  • Excluded from gap analysis
entities:
  # Short IDs (default behavior)
  requirement:
    label: Requirement
    id_prefix: REQ-
    # id_type: short  # This is the default

  # Sequential IDs for numbered tracking
  decision:
    label: Decision
    id_prefix: ADR-
    id_type: sequential

  # Manual IDs for components/modules
  component:
    label: Component
    id_type: manual
    # id_prefix not needed for manual IDs
    properties:
      name:
        type: string
        required: true

Creating entities:

# Short ID (default, auto-generated)
rela create requirement -P title="User authentication"
# Creates REQ-a3f8

# Sequential ID (auto-incremented)
rela create decision -P title="Use PostgreSQL for persistence"
# Creates ADR-001

# Manual ID (requires --id)
rela create component --id auth-service -P title="Authentication Service"
# Creates auth-service

Entity Descriptions

Add a description field to document the intent and usage of an entity type. Descriptions support markdown and are surfaced in the data-entry UI via help modals:

entities:
  decision:
    label: Decision
    description: |
      A decision records an important architectural choice and its rationale.

      Use decisions when:
      - Making technology choices (frameworks, databases, etc.)
      - Defining patterns or conventions
      - Resolving requirement conflicts

      Each decision should address one or more requirements.
    properties:
      # ...

In the data-entry UI, a help icon (?) appears next to the entity form title. Clicking it opens a modal showing the entity description, all properties with their descriptions, and available relations with cardinality constraints.

Entity Styling

Customize how entity types appear in graph visualizations with color and border_color:

entities:
  risk:
    label: Risk
    id_prefix: RISK-
    color: "#FFEBEE" # Light red fill
    border_color: "#C62828" # Dark red border
    properties:
      # ...

  control:
    label: Control
    id_prefix: CTL-
    color: "#E8F5E9" # Light green fill
    border_color: "#2E7D32" # Dark green border
    properties:
      # ...

Colors can be specified as:

  • Hex codes: #FF5722, #4CAF50
  • Named colors: red, green, lightblue

These colors are used in:

  • rela graph DOT output
  • rela schema --graphviz visualization
  • Data-entry graph views

Example Entity Type

entities:
  requirement:
    label: Requirement
    aliases: [req]
    id_prefix: REQ-
    properties:
      title:
        type: string
        required: true
      description:
        type: string
      status:
        type: status # References custom type above
        required: true
      priority:
        type: priority

Property Types

TypeDescriptionFilter Operators
stringFree-form text=, !=, =~ (regex), glob (*)
dateDate value (ISO 8601 by default)=, !=, <, <=, >, >=
datetimeTime-bearing instant (RFC3339, stored as UTC)=, !=, <, <=, >, >=
integerWhole number=, !=, <, <=, >, >=
booleanTrue or false=, !=
enumInline enum with values=, !=
fileFile attachment (stored under attachments/)N/A
<custom>Reference to a type defined in types:=, !=

Property Options

OptionDescription
required: trueProperty must be provided
defaultDefault value for the property
formatDate format (Go layout string, e.g., 2006-01-02)
descriptionDocumentation for the property
list: trueAllow multiple values (multi-select for enum types)
unique: trueNatural key: no two entities of the type may share a non-empty value (write-time 422; find pre-existing dups with rela analyze unique). Not for list properties.
maxFor file properties: max attachments (default 1)
acceptFor file properties: narrow the MIME allowlist (e.g. [application/pdf])
scan_cmdFor file properties: the scan command (array args); configuring it enables scanning
scan: offFor file properties: opt out of scanning despite a global scan_cmd
transformFor file properties: ordered byte transforms, each {cmd: [...]} or {image: {...}}

File attachments and max

A file property holds an attachment. By default it holds one file (max unset or 1): uploading a new file replaces the existing one. Set max above 1 to allow several files on the same property:

supporting_docs:
  type: file
  max: 5 # up to 5 files on this property

With max > 1 the property value is a list of attachment paths, the data-entry UI shows a multi-file picker (add up to max, remove individually), and uploading a file whose name already exists auto-suffixes it (report.pdfreport (1).pdf). max must be >= 1 and only applies to file properties.

Attachment security: scanning, allowlist & transforms

Uploaded attachments are inspected before they are stored. A native MIME allowlist (sniffed, blocks SVG/HTML/executables) is always on; virus scanning and byte transforms (metadata strip, resize, document disarm) are opt-in and driven by external commands you configure — rela ships no scanner or image library. Policy lives in a global attachments: block plus per-property overrides:

attachments:
  allow: default-safe # MIME allowlist preset (or a list)
  scan_cmd: [clamdscan, --no-summary, "{in}"] # configuring this enables scanning

entities:
  report:
    properties:
      evidence:
        type: file
        transform:
          - cmd: [exiftool, -all=, "{in}", -o, "{out}"] # strip metadata

Commands use array args (no shell — no injection) with {in}/{out} placeholders rela substitutes with temp paths it owns; each runs under a timeout and output-size cap. Configuring a scan_cmd enables scanning (fail-closed: rejects on a hit or when the scanner can't run); a property can opt out with scan: off. See the dedicated Attachment Security guide for the full configuration and vetted command recipes (ClamAV, vips, exiftool, qpdf, ImageMagick).

Date Formats

For date properties, specify the format using Go layout strings:

properties:
  valid_until:
    type: date
    format: "2006-01-02" # YYYY-MM-DD (ISO 8601, default)

Common formats:

FormatExampleGo Layout
ISO 86012025-02-012006-01-02 (default)
European01/02/202502/01/2006
US02/01/202501/02/2006
Long1 Feb 20252 Jan 2006

Datetime Properties

Use datetime for a time-bearing instant (a specific point in time, not just a calendar day). Unlike date, a datetime value carries a time-of-day.

properties:
  starts_at:
    type: datetime
    description: "When the event begins"

Semantics:

  • Stored as UTC RFC3339 (e.g. 2026-07-13T12:30:00Z). Values written through the data-entry app are always normalized to a UTC instant.
  • Bare dates are accepted as midnight UTC. A hand-edited value like 2026-07-13 on a datetime property is interpreted as 2026-07-13T00:00:00Z. (Note that such a midnight-UTC value displays on the previous evening in time zones west of UTC — see the data-entry docs.)
  • Values may be quoted or unquoted in YAML frontmatter. An unquoted timestamp is parsed as a timestamp; both round-trip correctly.
  • Filtering and sorting compare as instants (down to the second). Equality (=) is therefore strict-instant: starts_at=2026-07-13 (which parses as midnight) does not match 2026-07-13T12:30:00Z. Use >= and < to query a day or range.
  • Mixed date + datetime columns sort chronologically together.

The data-entry app renders datetime properties with a date+time picker and a configurable display time zone — see the data-entry guide.

A calendar-feed source can use a datetime property (as its date: or end_date:) to emit a timed event; a date property emits an all-day event. Start and end must be the same kind (all-day or timed), and timed events are rendered in UTC.

Property Type Examples

properties:
  # String - free-form text
  title:
    type: string
    required: true

  # Date with explicit format
  valid_until:
    type: date
    format: "2006-01-02"
    description: "When this evidence expires"

  # Integer
  risk_score:
    type: integer
    description: "Risk score from 1-10"

  # Boolean
  archived:
    type: boolean

  # Inline enum
  severity:
    type: enum
    values: [low, medium, high, critical]

  # Reference to custom type
  status:
    type: status
    required: true

  # File attachment
  screenshot:
    type: file
    description: "Screenshot of the issue"

  # Multi-select enum (list: true)
  tags:
    type: enum
    values: [frontend, backend, api, database, security]
    list: true # Allows selecting multiple values

Relations

Relations define how entity types can be connected:

FieldDescription
labelDisplay name
descriptionExplanation of the relation's meaning
fromSource entity types (list)
toTarget entity types (list)
inverseInverse relation definition (string or object)
symmetrictrue if relation is bidirectional
min_outgoingMinimum outgoing relations per from-side entity
max_outgoingMaximum outgoing relations per from-side entity
min_incomingMinimum incoming relations per to-side entity
max_incomingMaximum incoming relations per to-side entity

Example Relation

relations:
  addresses:
    label: addresses
    description: A decision addresses a requirement
    from: [decision]
    to: [requirement]
    min_outgoing: 1 # Each decision must address at least one requirement
    inverse: addressedBy # Simple form - the ID is also the display label

Inverse Relations

The inverse field can be specified in two forms:

Simple form (recommended for most cases):

inverse: addressedBy # The ID doubles as the display label

Without an explicit label, the ID itself is displayed — addressedBy renders as addressedBy. Labels are authored, never derived: rela does not convert an identifier into prose, because any such conversion encodes an English orthographic convention (word splitting, capitalization) that is wrong for most languages. Use the expanded form below to control the display text.

Expanded form (recommended whenever the ID is not the text you want shown):

inverse:
  id: addressedBy
  label: "is addressed by" # Custom label

The inverse ID is also the key under which incoming edges are grouped in the data-entry API's GET /api/v1/{plural}/{id}/relations response, and it's what surfaces in the help modal's "Incoming relations" section. See data-entry.md → Reverse Relations for how form widgets and list columns opt into reverse direction with direction: incoming.

Inverse name uniqueness

Inverse names must be globally unique across the metamodel. Two failure modes are rejected at load time:

  • inverse_name_collision — two relations declare the same inverse: ID. rela cannot tell which canonical relation an inverse-keyed lookup refers to, so this is treated as a structural error. Example:

    relations:
      blocks:
        inverse: blockedBy
      prevents:
        inverse: blockedBy # rejected: collides with `blocks`
    
  • inverse_shadows_canonical — a relation declares inverse: X where X is also the name of a separate canonical relation. The metamodel author most likely didn't mean for X to refer to two different relation sets at once. Example:

    relations:
      r1:
        inverse: r2
      r2: # rejected: shadows the inverse of `r1`
        from: [...]
        to: [...]
    

Exception: symmetric relations are allowed to be their own inverse:

relations:
  related-to:
    symmetric: true
    inverse: related-to # OK — symmetric self-inverse

Use the symmetric form when the relation has no preferred direction (e.g. "is related to" reads the same from either side).

Cardinality Constraints

Use cardinality to enforce rules:

relations:
  implements:
    label: implements
    from: [solution]
    to: [decision]
    min_outgoing: 1 # Every solution must implement at least one decision
    max_incoming: 1 # Each decision can only be implemented by one solution

Check violations with:

rela analyze cardinality

Symmetric Relations

For relations that work in both directions:

relations:
  conflictsWith:
    label: conflicts with
    from: [requirement, decision]
    to: [requirement, decision]
    symmetric: true

Default Metamodel

When you run rela init, this default metamodel is created:

version: "1.0"
namespace: "https://example.org/ontology/architecture#"

types:
  status:
    values: [draft, proposed, accepted, deprecated, rejected, retired]
    default: draft

  priority:
    values: [critical, high, medium, low]

entities:
  requirement:
    label: Requirement
    aliases: [req]
    id_prefix: REQ-
    properties:
      title:
        type: string
        required: true
      description:
        type: string
      status:
        type: status
        required: true
      priority:
        type: priority

  decision:
    label: Decision
    aliases: [dec, adr]
    id_prefixes: ["DEC-", "ADR-"]
    properties:
      title:
        type: string
        required: true
      rationale:
        type: string
      status:
        type: status
        required: true

  solution:
    label: Solution
    aliases: [sol]
    id_prefix: SOL-
    properties:
      title:
        type: string
        required: true
      description:
        type: string
      status:
        type: status

  component:
    label: Component
    aliases: [comp]
    id_prefixes: ["COMP-", "AC-", "TC-"]
    properties:
      title:
        type: string
        required: true

relations:
  addresses:
    label: addresses
    description: A decision addresses a requirement
    from: [decision]
    to: [requirement]
    inverse: addressedBy

  implements:
    label: implements
    description: A solution implements a decision
    from: [solution]
    to: [decision]
    inverse: implementedBy

  realizes:
    label: realizes
    description: A component realizes a solution
    from: [component]
    to: [solution]
    inverse: realizedBy

  dependsOn:
    label: depends on
    from: [component, solution, decision]
    to: [component, solution, decision]
    inverse: dependencyOf

Customization Examples

Adding a Risk Entity Type

entities:
  risk:
    label: Risk
    id_prefix: RISK-
    properties:
      title:
        type: string
        required: true
      likelihood:
        type: enum
        values: [low, medium, high, critical]
      impact:
        type: enum
        values: [low, medium, high, critical]

relations:
  mitigates:
    label: mitigates
    from: [decision, solution]
    to: [risk]
    inverse: mitigatedBy

Adding a Stakeholder Type

entities:
  stakeholder:
    label: Stakeholder
    aliases: [stk]
    id_prefix: STK-
    properties:
      name:
        type: string
        required: true
      role:
        type: string

relations:
  ownedBy:
    label: owned by
    from: [requirement, decision, component]
    to: [stakeholder]
    inverse: owns

Multiple ID Patterns

Support different ID conventions in the same project:

entities:
  requirement:
    label: Requirement
    aliases: [req]
    id_prefixes: ["REQ-", "FR-", "NFR-"] # Functional and non-functional

After Modifying the Metamodel

After editing schema.yaml:

# Rebuild the cache
rela sync

# Verify with
rela tui
# Press 'm' to see the updated metamodel

Note: Existing entities remain valid. The metamodel only affects creation and validation of new entities and relations.

Filtering Entities

Filter entities by property values using the --where flag:

# Exact match
rela list control --where "status=accepted"

# Glob pattern (strings only, use * for wildcard)
rela list control --where "iso27001=A.9.*"

# Regex match (strings only)
rela list control --where "title=~access.*policy"

# Date comparison
rela list evidence --where "valid_until<2025-02-01"
rela list evidence --where "valid_until>=2025-01-01"

# Integer comparison
rela list risk --where "risk_score>=5"
rela list risk --where "risk_score<10"

# Boolean filter
rela list evidence --where "archived=false"

# Multiple filters (AND logic)
rela list control --where "status=implemented" --where "applicability=applicable"

Filter Operators

OperatorDescriptionSupported Types
=Equal (exact match or glob)All types
!=Not equalAll types
<Less thandate, datetime, integer
<=Less than or equaldate, datetime, integer
>Greater thandate, datetime, integer
>=Greater than or equaldate, datetime, integer
=~Regex matchstring

Error Handling

Invalid filters produce helpful error messages:

# Unknown property
rela list control --where "typo=value"
# Error: unknown property "typo" for entity type "control"

# Invalid enum value
rela list control --where "status=invalid"
# Error: invalid value "invalid" (allowed: draft, proposed, accepted, ...)

# Invalid date format
rela list evidence --where "valid_until=not-a-date"
# Error: invalid date "not-a-date" for property "valid_until" (expected format: 2006-01-02)

# Invalid operator for type
rela list control --where "status>draft"
# Error: operator ">" not supported for enum property

Sorting Entities

Sort entities by property values using the --sort flag:

# Sort by property (ascending)
rela list control --sort iso27001

# Sort descending
rela list evidence --sort valid_until --desc

# Sort by ID (default)
rela list control --sort id

Sorting is type-aware:

  • string: Lexicographic (alphabetical)
  • enum/custom types: By the order defined in the type's values list (not alphabetical)
  • date: Chronological
  • datetime: Chronological, to the second (interleaves with date)
  • integer: Numeric
  • boolean: false before true

Entities with missing values for the sort property are placed at the end.

Default Sort Order

Entity types can declare a default sort order in the metamodel. This is used when no explicit sort is specified in a query or CLI command:

entities:
  ticket:
    label: Ticket
    id_prefix: "TKT-"
    default_sort:
      - property: priority
      - property: due_date
        direction: asc
    properties:
      # ...

Each entry in default_sort is a sort criterion applied in order (first entry is the primary key). The direction field is optional and defaults to "asc". Supported values: "asc" or "desc".

You can sort by any property defined on the entity, plus two virtual properties:

  • id — sorts by entity ID
  • modified — sorts by file modification time

Sort in Search Queries

The TUI search screen and data entry search bar support a sort: clause:

sort:priority                     # sort by priority ascending
sort:priority:desc                # sort by priority descending
sort:id:desc                      # sort by entity ID descending
sort:modified:desc                # sort by modification time (newest first)
sort:priority:desc sort:title     # multi-sort: priority desc, then title asc

When no sort: clause is present:

  1. If all results are the same entity type and that type has default_sort, it is used
  2. Otherwise, results are sorted by ID ascending

Custom Validation Rules

Define validation rules to enforce business constraints on your entities. Validation rules use the same filter syntax as --where filters.

Validation Rule Structure

validations:
  - name: rule-identifier # Unique name for the rule
    description: "Human-readable description shown in output"
    entity_type: requirement # Optional: limit to specific type
    when: # Optional: IF these conditions match...
      - "status=accepted"
    then: # THEN these must be true
      - "priority!="
    severity: error # Optional: "error" or "warning" (default)

How Validation Rules Work

  1. Select entities: If entity_type is specified, only those entities are checked
  2. Apply when filter: If when is specified, only entities satisfying ALL when conditions are subject to the rule
  3. Check then conditions: Matched entities must satisfy ALL then conditions
  4. Report violations: Entities that match when but don't satisfy then are reported

Example Validation Rules

validations:
  # Accepted requirements must have a priority
  - name: accepted-needs-priority
    description: "Accepted requirements must have a priority assigned"
    entity_type: requirement
    when:
      - "status=accepted"
    then:
      - "priority!="
    severity: error

  # All decisions should have a rationale (no 'when' = applies to all)
  - name: decisions-need-rationale
    description: "Decisions should have a rationale documented"
    entity_type: decision
    then:
      - "rationale!="
    severity: warning

  # High priority requirements must have a description
  - name: high-priority-needs-description
    description: "High priority requirements need detailed descriptions"
    entity_type: requirement
    when:
      - "priority=high"
    then:
      - "description!="
    severity: warning

  # ADRs should follow naming convention
  - name: adr-naming-convention
    description: "ADRs should follow the ADR-NNN naming pattern"
    entity_type: decision
    then:
      - "title=~^ADR-\\d+:"
    severity: warning

Filter Operators in Validations

Validation rules support all the same operators as --where filters:

OperatorExampleDescription
=status=acceptedEquals (supports glob patterns with *)
!=owner!=Not equals (use empty value to check "has value")
<risk_score<5Less than (dates, integers)
<=deadline<=2025-12-31Less than or equal
>priority>lowGreater than
>=created>=2025-01-01Greater than or equal
=~title=~^ADR-\\d+Regex match (strings)

Content Validation

In addition to property-based conditions, validation rules can check markdown content structure using the content field. This validates the presence of required headers in entity markdown files.

validations:
  - name: adr-structure
    description: "ADRs must have Context and Decision headers"
    entity_type: decision
    when:
      - "status=accepted"
    content:
      required-headers:
        - "## Context"
        - "## Decision"

Required Headers

The required-headers field accepts a list of header checks. Each check can be:

  1. Exact match (string): The header must match exactly, including the # prefix

    required-headers:
      - "## Context" # Requires exactly "## Context"
      - "### Details" # Requires exactly "### Details"
    
  2. Pattern match (regex): Use the pattern: prefix for flexible matching

    required-headers:
      - pattern: "## (Alternative|Alternatives)" # Matches either spelling
      - pattern: "## .+ Analysis" # Matches any "## X Analysis" header
    

Content Validation Example

validations:
  # ADRs must follow the standard structure
  - name: adr-required-sections
    description: "Accepted ADRs must have Context, Decision, and Consequences sections"
    entity_type: decision
    when:
      - "status=accepted"
    content:
      required-headers:
        - "## Context"
        - "## Decision"
        - "## Consequences"
    severity: error

  # User stories should have acceptance criteria
  - name: story-acceptance-criteria
    description: "User stories should have acceptance criteria"
    entity_type: requirement
    when:
      - "title=~^As a"
    content:
      required-headers:
        - pattern: "## (Acceptance Criteria|AC)"
    severity: warning

How Content Validation Works

  1. Headers are extracted from the entity's markdown content using a proper parser
  2. Headers inside code blocks (fenced or indented) are ignored
  3. Each required header is checked against the extracted headers
  4. If any required header is missing, the entity violates the rule

Lua Validation

For complex validation logic that goes beyond property filters and content checks, you can use Lua scripts. This enables cross-entity lookups, custom calculations, and sophisticated business rules.

Inline Lua Code

Use the lua field for short validation logic:

validations:
  - name: status-required
    description: "Status must not be empty"
    entity_type: ticket
    lua: |
      local status = entity.properties.status
      if status == nil or status == "" then
        return { message = "Status is required" }
      end
      return nil
    severity: error

External Lua Scripts

For longer scripts, use lua_file to reference a script in the validations/ directory. Use lua_args to pass parameters to the script (available as rela.args):

validations:
  - name: component-coverage-high
    description: "Critical components need 90% coverage"
    entity_type: component
    when:
      - "criticality=high"
    lua_file: check-coverage.lua
    lua_args: ["90"]
    severity: error
  - name: component-coverage-standard
    description: "Components need 80% coverage"
    entity_type: component
    lua_file: check-coverage.lua
    lua_args: ["80"]
    severity: warning
-- validations/check-coverage.lua
-- Entity is available as a global variable
-- Arguments are available via rela.args

local min_coverage = tonumber(rela.args[1]) or 80

local coverage = entity.properties.test_coverage
if coverage == nil then
  return nil  -- No coverage data, pass
end

-- Parse percentage (e.g., "85%" -> 85)
local value = tonumber(string.match(coverage, "(%d+)"))
if value == nil then
  return nil  -- Can't parse, pass
end

if value < min_coverage then
  return { message = "Coverage is " .. value .. "%, minimum is " .. min_coverage .. "%" }
end
return nil

Entity Context

The entity global variable provides access to the entity being validated:

FieldTypeDescription
entity.idstringEntity ID (e.g., "REQ-001")
entity.typestringEntity type (e.g., "requirement")
entity.propertiestableProperty key-value pairs
entity.contentstringMarkdown body content

Access properties directly via entity.properties.status or entity.properties["my-field"].

Cross-Entity Lookups

Lua validation scripts have read-only access to the workspace for cross-entity validation:

-- Get another entity by ID
local related = rela.get_entity("REQ-001")
if related and related.properties.status ~= "approved" then
  return { message = "Related requirement must be approved" }
end

-- List entities by type
local components = rela.list_entities("component")
for _, comp in ipairs(components) do
  -- Check each component...
end

-- Trace dependencies
local deps = rela.trace_from(entity.id, 2)
for _, step in ipairs(deps.path) do
  -- Check dependency chain...
end
return nil

Return Value Semantics

Lua scripts return nil to pass validation, or a table (or array of tables) to report violations:

-- Pass: return nil or nothing
return nil

-- Single violation with custom message
return { message = "Status is required" }

-- Single violation with custom severity (overrides rule default)
return { message = "Consider adding a description", severity = "warning" }

-- Multiple violations from one rule
return {
  { message = "Missing owner", severity = "warning" },
  { message = "Priority not set", severity = "error" }
}

Each violation table has:

FieldTypeDescription
messagestringCustom error message (required)
severitystring"error" or "warning" (optional, defaults to rule's severity)

Security and Sandboxing

Lua validation runs in a sandboxed environment:

  • Read-only workspace: Scripts cannot create, update, or delete entities
  • Execution timeout: Scripts are terminated after 5 seconds to prevent infinite loops
  • Path restrictions: lua_file scripts must be in the validations/ directory with .lua extension
  • No file I/O: Scripts cannot read or write files directly

Errors in Lua scripts (syntax errors, runtime errors, timeouts) are logged and the validation rule is skipped ("fail open") to avoid blocking the entire validation run.

Running Validations

# Run only custom validations
rela analyze validations

# Run all analyses including validations
rela analyze all

Validation Output

$ rela analyze validations
✗ Accepted requirements must have a priority assigned (2):
  REQ-003: User authentication
  REQ-007: Data encryption
⚠ Decisions should have a rationale documented (1):
  DEC-002: Use PostgreSQL
Found 2 errors, 1 warnings across 2 rules

Severity Levels

  • error: Critical violations that should be fixed. Displayed with ✗
  • warning: Recommendations that may need attention. Displayed with ⚠

Tips

  1. Start with warnings: Begin with severity: warning and promote to error once your data is cleaned up
  2. Use specific entity types: Narrow rules to specific types when possible for clearer error messages
  3. Combine with cardinality: Use cardinality constraints for relation rules, validations for property rules
  4. Check for empty values: Use property!= to require that a property has any value

Automations

Automations are trigger-action rules that execute when entities change. They enable workflow automation, automatic property updates, and entity creation based on state transitions.

Automation Structure

automations:
  - name: automation-name
    description: "Human-readable description"
    on:
      # Trigger conditions
    do:
      # Actions to perform

Triggers

Automations fire based on entity changes:

Trigger FieldDescriptionExample
entityEntity types to watch (string or list)[ticket, bug]
propertyProperty name to monitorstatus
becomesValue the property changed toin-progress
fromValue the property changed frombacklog
createdFires when entity is createdtrue
relation_createdFires when this relation type is createdimplements
relation_removedFires when this relation type is removedimplements
whenProperty conditions that must match (AND)["kind=enhancement"]

Conditional Triggers

Use when to add property conditions that must be satisfied for the automation to fire. This uses the same filter syntax as validation rules.

automations:
  - name: mark-enhancement-for-docs
    description: Mark enhancement tickets for documentation review
    on:
      entity: ticket
      property: status
      becomes: review
      when:
        - "kind=enhancement"
    do:
      - set: needs_docs
        value: "true"

Multiple conditions use AND logic (all must match):

on:
  entity: ticket
  property: status
  becomes: review
  when:
    - "kind=enhancement"
    - "priority=high"

Supported operators: =, !=, <, <=, >, >=, =~ (regex).

Note: Conditions are evaluated against the entity's NEW state (after the change). For property change triggers, use from to filter on the old value of the changed property.

Actions

Actions execute when triggers match:

Set Property:

do:
  - set: started_at
    value: "{{today}}"

Create Relation:

do:
  - create_relation:
      relation: implements
      to: "{{entity.parent}}"

Create Entity (with optional relation):

do:
  - create_entity:
      type: checklist
      properties:
        title: "Planning: {{new.title}}"
        status: in-progress
      relation: has-planning
      if_exists: skip

Template Variables

Automation values support template substitution:

VariableDescription
{{today}}Current date in ISO 8601 format
{{new.title}}Property value from the changed entity
{{new.status}}Any property from the changed entity
{{entity.id}}Entity ID
{{user.name}}Current user's name

Example: Workflow Checklists

Automatically create workflow checklists when tickets transition through stages:

automations:
  # Create planning checklist when ticket enters planning
  - name: ticket-planning-checklist
    description: Create planning checklist when ticket enters planning
    on:
      entity: [ticket]
      property: status
      becomes: planning
    do:
      - create_entity:
          type: planning-checklist
          properties:
            title: "Planning: {{new.title}}"
            status: in-progress
          relation: has-planning
          if_exists: skip

  # Create implementation checklist when ticket enters in-progress
  - name: ticket-implementation-checklist
    description: Create implementation checklist when ticket starts
    on:
      entity: [ticket, bug]
      property: status
      becomes: in-progress
    do:
      - create_entity:
          type: implementation-checklist
          properties:
            title: "Implementation: {{new.title}}"
            status: in-progress
          relation: has-implementation
          if_exists: skip

  # Create review checklist when ticket enters review
  - name: ticket-review-checklist
    description: Create review checklist when ticket enters review
    on:
      entity: [ticket, bug]
      property: status
      becomes: review
    do:
      - create_entity:
          type: review-checklist
          properties:
            title: "Review: {{new.title}}"
            status: in-progress
          relation: has-review
          if_exists: skip

Example: Status Tracking

Track when work started and by whom:

automations:
  - name: track-started
    description: Record when work started
    on:
      entity: [ticket, bug]
      property: status
      becomes: in-progress
    do:
      - set: started_at
        value: "{{today}}"
      - set: started_by
        value: "{{user.name}}"

Automation Options

FieldDescription
if_existsBehavior when create_entity target exists: skip

Best Practices

  1. Use descriptive names: Name automations after what they accomplish
  2. Keep actions focused: Each automation should do one logical thing
  3. Use if_exists: skip: Prevent duplicate entities when re-entering states
  4. Document with description: Explain the workflow the automation supports