aontu

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 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, an ETL pipeline where jobs feed jobs. Write it as spec.aon:

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:

@"./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, 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:

$ 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:

@"./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:

$ 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:

$ 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:

@"./pipeline.aon"

jobs: metrics: fedBy: []
jobs: transform: feeds: [path($.jobs.load) path($.jobs.metrics)]
$ 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, the verb under aontu relations, and the live version, with its checks, is use-cases/12-relations. Once the edges hold, ask what they connect: Query reachability between entities.