---
title: "Check that components agree about their relations"
description: "Declare a relation once at the field with rel(), acyclic() and inverse(), and have the whole model's edge set checked."
source: "https://aontu.dev/how-to/check-relations/"
---

# Check that components agree about their relations

Declare a relation once at the field with rel(), acyclic() and inverse(), and have the whole model's edge set checked.

Rendered from [`docs/how-to/check-relations.md`](https://github.com/aontu-lang/aontu/blob/main/docs/how-to/check-relations.md) in the engine repository, where a correction belongs, and where the test suite executes every example on this page.

A model can hold field by field and still be wrong as a graph: a dependency loop nobody meant, an edge whose other end never wrote it down. Those are facts about the _finished_ model, so you declare them once, at the field, and the verdict lands when every edge is known.

## Declare the relation at the field

The vocabulary below is a trimmed version of [use-cases/12-relations](https://aontu.dev/use-cases/12-relations), an ETL pipeline where jobs feed jobs. Write it as `spec.aon`:

```aontu
spec: hide({
  Job: {
    kind: job
    feeds?: rel($.spec.JobShape) & acyclic() & inverse(fedBy)
    fedBy?: rel($.spec.JobShape)
  }
  JobShape: kind: job
})
```

One conjunction on `feeds` declares the whole contract. `rel(t)` makes the field’s strings checked entity addresses and flows the endpoint type `t` into every target; `acyclic()` and `inverse(fedBy)` are the graph atoms: declarations that unification carries along inert, whose verdict lands at generation. Data documents then stay plain lists of names. Write the topology as `pipeline.aon`:

```aontu
@"./spec.aon"

jobs: { &: $.spec.Job }
jobs: extract: feeds: [path($.jobs.transform)]
jobs: transform: { fedBy: [path($.jobs.extract)] feeds: [path($.jobs.load)] }
jobs: load: fedBy: [path($.jobs.transform)]
```

Every list is plain: each entry is a [tree address](https://aontu.dev/docs/reference-language#checked-links-refert), which is what `rel()` checks. Both directions are written out by hand, because `inverse()` only checks that the mirror exists; it never writes the mirror for you. Run the checks:

```sh
$ aontu relations pipeline.aon
verdict: pass
```

Generating the document (`aontu pipeline.aon`) is unaffected by any of this, and the lists come out as the same plain strings the author wrote: `rel()` checks addresses, it never rewrites them.

## A cycle is a located refusal

Suppose a change makes `load` feed `extract`, closing the DAG into a loop. Write the change as `cycle.aon`, patching both directions in:

```aontu
@"./pipeline.aon"
jobs: { load:feeds: [path($.jobs.extract)] extract:fedBy: [path($.jobs.load)] }
```

Every field still unifies (nothing contradicts locally), but the edge set now violates `acyclic()`, so _generation_ refuses, with a located error at an edge on the loop:

```sh
$ aontu cycle.aon
[aontu/relation_cycle]: Cannot relate value at path $.jobs.extract.feeds
...
$ echo $?
1
```

The verb reports the same verdict without generating, and names the nodes the cycle runs through, closing back on the first:

```sh
$ aontu relations cycle.aon
verdict: fail

$.jobs.extract.feeds.0  feeds: cycle $.jobs.extract -> $.jobs.transform -> $.jobs.load -> $.jobs.extract
$ echo $?
1
```

Each verdict line is one finding: the position of the offending edge, the relation, and the detail. Findings are sorted by position, so the report diffs cleanly between runs.

## A missing inverse names the missing entry

Add a `metrics` job that taps the transform output, and forget to record the feeder on its `fedBy`. Write it as `metrics.aon`:

```aontu
@"./pipeline.aon"

jobs: metrics: fedBy: []
jobs: transform: feeds: [path($.jobs.load) path($.jobs.metrics)]
```

```sh
$ aontu relations metrics.aon
verdict: fail

$.jobs.transform.feeds.1  feeds: $.jobs.metrics does not list $.jobs.transform under fedBy
$ echo $?
1
```

The finding says exactly which entry to write. Generation refuses the same model with a located `[aontu/relation_inverse_missing]` at that edge.

## What to watch for

Acyclicity and inverse consistency are deliberately _not_ lattice constraints: both are global and non-monotone (one more edge can make an acyclic graph cyclic), so unification could never hold them open at all. The endpoint _type_ is different: `rel(t)` flows `t` into each far end at the site, so an edge landing on a wrong-shaped entity is an ordinary located evaluation error, and the verb answers `verdict: error` (exit 4) for a document that does not stand up at all. Exit codes are otherwise `0` pass and `1` fail, with `--format json` for a machine-readable report.

The atoms are specified in [Declared relations](https://aontu.dev/docs/reference-language#declared-relations), the verb under [`aontu relations`](https://aontu.dev/docs/reference-api#aontu-relations), and the live version, with its checks, is [use-cases/12-relations](https://aontu.dev/use-cases/12-relations). Once the edges hold, ask what they connect: [Query reachability between entities](https://aontu.dev/how-to/query-reachability).
