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 readsmetamodel.yamlwhen noschema.yamlis present, and warns once at startup. Runrela migrateto rename the file. The legacy name will keep working until a future major version. If both files exist,schema.yamlis used and themetamodel.yamlis ignored —rela migratereports 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
| Situation | Error |
|---|---|
| Duplicate definition | duplicate entity "control": defined in both a.yaml and b.yaml |
| Circular include | circular include detected: a.yaml → b.yaml → a.yaml |
| File not found | include file not found: missing.yaml (included from schema.yaml) |
| Root-only field | included 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
labelsrenders 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
enumoutput stay value-based. labelsis 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
labelsmap on such a property is ignored (mirroring how an inlinevalueslist 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.
descriptionsis optional and independent oflabels: 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
| Field | Meaning |
|---|---|
from / to | Source and target values; both must be declared in values. |
initial | The only value a create may set (else default). New entities are pinned to it — a create cannot enter a guarded mid-lifecycle state. |
guard | An ACL permission the acting principal must hold for the move. Enforced on served paths; inert on a direct CLI write with no policy. |
when | A predicate (same language as validations, evaluated against the entity + graph) that must hold for the move. |
label | Optional 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. |
help | Optional 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:
| Field | Description |
|---|---|
pattern | Regex pattern that values must match |
error | User-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
requiredhandle 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 textdate- Date valuesdatetime- Time-bearing instants (date + time)integer- Whole numbersboolean- True/false valuesenum- Inline enumeration (usevalues: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:
| Field | Description |
|---|---|
label | Display name |
label_plural | Plural display name (defaults to label + "s") |
description | Documentation explaining intent and usage (optional) |
aliases | Alternative names for CLI (e.g., req for requirement) |
id_type | short (default), sequential, or manual - controls ID generation |
id_prefix | Single ID prefix (e.g., REQ-) |
id_prefixes | Multiple ID prefixes (e.g., ["DEC-", "ADR-"]) |
properties | Property definitions |
default_sort | Default sort order for list views |
color | Fill color for graph visualizations (hex or named) |
border_color | Border color for graph visualizations |
display_property | Property 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:
| Type | Description | Example IDs |
|---|---|---|
short | Random base36 IDs (default) | REQ-a3f8, REQ-k2m9 |
sequential | Auto-incremented numeric IDs | REQ-001, REQ-002, DEC-003 |
manual | Manually specified string IDs | auth-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
--idflag 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 graphDOT outputrela schema --graphvizvisualization- 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
| Type | Description | Filter Operators |
|---|---|---|
string | Free-form text | =, !=, =~ (regex), glob (*) |
date | Date value (ISO 8601 by default) | =, !=, <, <=, >, >= |
datetime | Time-bearing instant (RFC3339, stored as UTC) | =, !=, <, <=, >, >= |
integer | Whole number | =, !=, <, <=, >, >= |
boolean | True or false | =, != |
enum | Inline enum with values | =, != |
file | File attachment (stored under attachments/) | N/A |
<custom> | Reference to a type defined in types: | =, != |
Property Options
| Option | Description |
|---|---|
required: true | Property must be provided |
default | Default value for the property |
format | Date format (Go layout string, e.g., 2006-01-02) |
description | Documentation for the property |
list: true | Allow multiple values (multi-select for enum types) |
unique: true | Natural 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. |
max | For file properties: max attachments (default 1) |
accept | For file properties: narrow the MIME allowlist (e.g. [application/pdf]) |
scan_cmd | For file properties: the scan command (array args); configuring it enables scanning |
scan: off | For file properties: opt out of scanning despite a global scan_cmd |
transform | For 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.pdf → report (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:
| Format | Example | Go Layout |
|---|---|---|
| ISO 8601 | 2025-02-01 | 2006-01-02 (default) |
| European | 01/02/2025 | 02/01/2006 |
| US | 02/01/2025 | 01/02/2006 |
| Long | 1 Feb 2025 | 2 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-13on adatetimeproperty is interpreted as2026-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 match2026-07-13T12:30:00Z. Use>=and<to query a day or range. - Mixed
date+datetimecolumns 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:
| Field | Description |
|---|---|
label | Display name |
description | Explanation of the relation's meaning |
from | Source entity types (list) |
to | Target entity types (list) |
inverse | Inverse relation definition (string or object) |
symmetric | true if relation is bidirectional |
min_outgoing | Minimum outgoing relations per from-side entity |
max_outgoing | Maximum outgoing relations per from-side entity |
min_incoming | Minimum incoming relations per to-side entity |
max_incoming | Maximum 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 sameinverse: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 declaresinverse: XwhereXis also the name of a separate canonical relation. The metamodel author most likely didn't mean forXto 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
| Operator | Description | Supported Types |
|---|---|---|
= | Equal (exact match or glob) | All types |
!= | Not equal | All types |
< | Less than | date, datetime, integer |
<= | Less than or equal | date, datetime, integer |
> | Greater than | date, datetime, integer |
>= | Greater than or equal | date, datetime, integer |
=~ | Regex match | string |
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'svalueslist (not alphabetical)date: Chronologicaldatetime: Chronological, to the second (interleaves withdate)integer: Numericboolean:falsebeforetrue
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 IDmodified— 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:
- If all results are the same entity type and that type has
default_sort, it is used - 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
- Select entities: If
entity_typeis specified, only those entities are checked - Apply when filter: If
whenis specified, only entities satisfying ALL when conditions are subject to the rule - Check then conditions: Matched entities must satisfy ALL
thenconditions - Report violations: Entities that match
whenbut don't satisfythenare 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:
| Operator | Example | Description |
|---|---|---|
= | status=accepted | Equals (supports glob patterns with *) |
!= | owner!= | Not equals (use empty value to check "has value") |
< | risk_score<5 | Less than (dates, integers) |
<= | deadline<=2025-12-31 | Less than or equal |
> | priority>low | Greater than |
>= | created>=2025-01-01 | Greater 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:
-
Exact match (string): The header must match exactly, including the
#prefixrequired-headers: - "## Context" # Requires exactly "## Context" - "### Details" # Requires exactly "### Details" -
Pattern match (regex): Use the
pattern:prefix for flexible matchingrequired-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
- Headers are extracted from the entity's markdown content using a proper parser
- Headers inside code blocks (fenced or indented) are ignored
- Each required header is checked against the extracted headers
- 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:
| Field | Type | Description |
|---|---|---|
entity.id | string | Entity ID (e.g., "REQ-001") |
entity.type | string | Entity type (e.g., "requirement") |
entity.properties | table | Property key-value pairs |
entity.content | string | Markdown 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:
| Field | Type | Description |
|---|---|---|
message | string | Custom error message (required) |
severity | string | "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_filescripts must be in thevalidations/directory with.luaextension - 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
- Start with warnings: Begin with
severity: warningand promote toerroronce your data is cleaned up - Use specific entity types: Narrow rules to specific types when possible for clearer error messages
- Combine with cardinality: Use cardinality constraints for relation rules, validations for property rules
- 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 Field | Description | Example |
|---|---|---|
entity | Entity types to watch (string or list) | [ticket, bug] |
property | Property name to monitor | status |
becomes | Value the property changed to | in-progress |
from | Value the property changed from | backlog |
created | Fires when entity is created | true |
relation_created | Fires when this relation type is created | implements |
relation_removed | Fires when this relation type is removed | implements |
when | Property 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:
| Variable | Description |
|---|---|
{{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
| Field | Description |
|---|---|
if_exists | Behavior when create_entity target exists: skip |
Best Practices
- Use descriptive names: Name automations after what they accomplish
- Keep actions focused: Each automation should do one logical thing
- Use
if_exists: skip: Prevent duplicate entities when re-entering states - Document with description: Explain the workflow the automation supports