01. A company-wide service catalog as system ontology
Company-wide service catalog as system ontology (two views of the same entities)
Exercises refer() over tree paths, relations (acyclic + inverse), @"aontu:system", get/why, vet-gated onboarding
Rendered from
use-cases/01-service-catalog/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/01-service-catalog/.
Scenario
A Backstage-style service catalog for “Acme”: eight services across
three teams/domains (payments, identity, platform), each with owner,
tier, lifecycle, ports, protocols and dependsOn relations. Two
organisational units describe the same services from different
angles:
catalog.aon: the catalog view: what each service is (owner, tier, description, dependencies), organised by domain.deploy.aon: the deployment view: what each cluster runs (image, replicas, ports), organised by region and cluster.
The deployment view REFERENCES its catalog entry’s org facts, so one
evaluation of system.aon brings the two into contact and a drift
between them fails at the deploy position. This is the core enterprise
problem: the org chart and the runtime both hold facts about one
logical thing, and any drift between them should be an error, not a
silent fork. It is also the ground-truth-ontology problem for AI
agents: an agent must be able to pull one service’s complete truth
into context (aontu model get), ask where a fact came from (aontu model why),
and have its own emitted candidates checked (aontu vet, rel()).
The model tree
system.aon is one evaluation joining four things: the bundled
vocabulary (std), Acme’s own (spec), the catalog view, and the
deployment view. catalog and deploy hold the same services seen from
different angles (what each one IS, and where each one RUNS) and
everything below them is domains or regions, then services, then fields.
$
├── %CatalogAddr re("^\\$[.]catalog[.]")
├── %Description string&length(integer&min(10))
├── %Lifecycle *"production"|"production"|"e...
├── %Owner re("^team-[a-z]+$")
├── catalog
│ └── domains (3)
├── deploy
│ └── regions (2)
├── spec
│ ├── CandidateShape (6)
│ ├── CatalogEntry (8)
│ ├── PortSpec (3)
│ └── Workload (4)
└── std
├── Component (1)
├── Port (2)
└── Service (2)
aontu view doc --depth 2 system.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
Model design
| File | Role | Features exercised |
|---|---|---|
system.aon | root: one evaluation joining the vocabulary and both views | @"aontu:system", @"./..." includes, hide() |
spec.aon | Acme vocabulary over the bundled one | $.aontu.System.Service, $.aontu.System.Port, conjunction-as-subclassing, re/min/max/length atoms, * defaults, optional ? keys, rel(t) with re() address constraints, acyclic(), inverse() |
catalog.aon | catalog view | per-domain &: spreads stamping owner + schema, path() address lists |
deploy.aon | deployment view | references into the catalog view, defaults (replicas: *2) |
queries/queries.aon | instance-of queries | filter, map union as index |
bad/*.aon | change requests that must be refused | cycle, missing inverse, cross-view contradiction, wrong-kind endpoint, wrong-kind rel() target |
proposals/*.aon + data/* | agent-emitted candidates | JSON-as-aontu, vet --at --closed, rel() existence checks |
Relations are declared on the field that holds them. In spec.aon:
%CatalogAddr = re("^\\$[.]catalog[.]")
dependsOn?: rel($.aontu.System.Service) & %CatalogAddr & acyclic() & inverse(dependedOnBy)
dependedOnBy?: rel($.aontu.System.Service) & %CatalogAddr
%CatalogAddr is an alias: %name = value at the top level of the
document declares one, %name in value position uses it, and that is
all it is. An alias does not generate and does not appear in canon, so
spec.aon with the names and spec.aon with the pattern written out at
both use sites are the same document and produce the same aon1-
hash: the change is readability, and the engine cannot tell. What it buys
is that a relation and its inverse can no longer be given different
address shapes by a typo. %Owner, %Lifecycle and %Description do
the same for the three fields CatalogEntry and CandidateShape both
promise, which have to be spelled twice because the vet anchor is
deliberately written out self-contained (gap 2), and an alias is not a
path ($.%Owner is refused at any depth), so naming them does not
reintroduce the reference that gap is about.
The key is the predicate. rel($.aontu.System.Service) types every far end,
and the type flows into each target instead of being repeated at every
link site; the held re() constrains every address, so a service may
depend on a catalog entry and never on a workload; acyclic() and
inverse(dependedOnBy) declare the graph properties the engine
checks. Data documents stay plain lists of path(...) values. The
inverse is written on every target, and aontu relations checks that
every edge is mirrored.
The two views meet at the deploy positions. Each workload in
deploy.aon references the owner and tier of its catalog entry,
so a contradiction between the views is a located error. The reference
is directional (the catalog is not changed by what a cluster runs),
which is what lets this file be one of several deployment views over
one catalog. Both views name the same $.aontu.System.Service and
$.spec.PortSpec templates, so a port has one shape wherever it
appears.
Spreads carry the schema. One line per domain
(&: $.spec.CatalogEntry & {owner: "team-payments"}) applies the
whole vocabulary and stamps ownership, and the * defaults fill in
replicas: 2, direction: "in" and lifecycle: "production"
wherever a file does not override them.
Change requests are overlays. Every bad/*.aon and proposals/*.aon
is @"../system.aon" plus the proposed delta, so verifying a change
before editing the base model costs one file and one CLI call. Lists
unify positionally, so a proposal that adds an inverse entry restates
the target’s dependedOnBy list in full and in order
(proposals/onboard-webhooks.aon).
Agent-emitted JSON is already aontu. data/candidate-webhooks.json is
used twice unmodified: vetted against $.spec.CandidateShape, the
candidate’s shape written out self-contained, and loaded at a hidden
key in proposals/onboard-webhooks.aon and pulled into the catalog by
reference ($.spec.CatalogEntry & $.candidate), where the full
CatalogEntry schema and the rel() checks apply.
Instance-of queries run over a flat index. queries/queries.aon
unifies filter(services, {}) for each domain: an empty condition
keeps every entry, already stamped by its domain’s spread, and leaves
the spread’s template behind, so the three maps unify into one index
that filter(.index, {tier: 1}) answers over.
The catalog, drawn
Drawn from this model by
aontu view and pinned as
goldens by check.sh. The graph groups the services by their generated
owner, aontu view graph --relation dependsOn --group-by owner:
Mermaid source
flowchart LR
subgraph g0["team-identity"]
n_auth["auth"]
n_directory["directory"]
end
subgraph g1["team-payments"]
n_ledger["ledger"]
n_payments["payments"]
n_risk["risk"]
end
subgraph g2["team-platform"]
n_email["email"]
n_gateway["gateway"]
n_notify["notify"]
end
n_auth -->|"dependsOn"| n_directory
n_gateway -->|"dependsOn"| n_auth
n_gateway -->|"dependsOn"| n_payments
n_notify -->|"dependsOn"| n_email
n_payments -->|"dependsOn"| n_auth
n_payments -->|"dependsOn"| n_ledger
n_payments -->|"dependsOn"| n_notify
n_payments -->|"dependsOn"| n_risk
n_risk -->|"dependsOn"| n_directoryThe same graph as a dependency-structure matrix in partition order
with the closure marked, aontu view matrix --relation dependsOn --order partition --closure. A mark at (row, column) means the row
service depends on the column service: X directly, + through
others. The SVG is the same figure under --as svg, the same cells on
an integer grid, pinned as expected/diagram-matrix.svg:
The same figure as text
1 2 3 4 5 6 7 8
directory 1 \ . . . . . . .
email 2 . \ . . . . . .
ledger 3 . . \ . . . . .
auth 4 X . . \ . . . .
notify 5 . X . . \ . . .
risk 6 X . . . . \ . .
payments 7 + + X X X X \ .
gateway 8 + + + X + + X \
# above-diagonal direct cells: 0
The partition order is a perfect lower triangle, and that is the
acyclicity proof: above-diagonal direct cells: 0 is not an
annotation on the picture, it is the picture’s shape. The order is a
layering nobody wrote down (leaves (directory, email, ledger),
then auth/notify/risk, then payments, then gateway) and
rows 7 and 8 are the coupling: gateway reaches everything through
two hops.
The matrix is the form the empirical literature prefers past about twenty vertices: Ghoniem, Fekete and Castagliola (InfoVis 2004) found matrices beat node-link for most tasks at that size, with path-finding the exception; Sangal et al. (OOPSLA 2005) is the software-dependency application. At eight services both are readable, which is the point of showing them together: the node-link picture reads as a shape, the matrix reads as a table, and only the matrix stays legible as the catalog grows.
What check.sh proves
check.sh runs 21 checks through the real CLI: golden diffs for the
merged model, get slices and query results; grep-by-error-code
(never byte-compared error text) for the refusals; relations,
reaches and vet verdicts on good, bad and post-proposal models.
-
system.aonevaluates toexpected/system.json: two views of eight entities, joined through the deployment view’s references, against the bundledaontu:systemvocabulary. -
--canonrendersacyclic()andinverse("dependedOnBy")back at their fields, so the canonical form distinguishes documents that disagree about their relations. -
aontu relations system.aonanswersverdict: pass:dependsOnis acyclic and every edge has itsdependedOnBymirror. -
aontu model get '$.deploy.regions.eu1.clusters.core.workloads.payments'matchesexpected/payments-slice.json. The workload carries theownerandtierit references from the catalog alongside its ownimage,replicasandports; the catalog entry keeps only what the catalog states. -
get --keyson theeu1/corecluster lists its five workloads. -
aontu model whyon a workload’s own field names thedeploy.aonline that wrote it:$ aontu model why '$.deploy.regions.eu1.clusters.core.workloads.payments.replicas' system.aon $.deploy.regions.eu1.clusters.core.workloads.payments.replicas = 6 1. 6 .../deploy.aon:20:27 2. *2|(min(1)&max(48)&integer) .../spec.aon:83:15 -
aontu model whyon a field the workload takes from the catalog names the reference and the schema row that admits the value, so a referenced field carries its provenance into the deploy view:$ aontu model why '$.deploy.regions.eu1.clusters.core.workloads.payments.tier' system.aon $.deploy.regions.eu1.clusters.core.workloads.payments.tier = 1 1. $.catalog.domains.payments.services.payments.tier .../deploy.aon:17:23 (ref) 2. (1|2)|3 .../spec.aon:58:11 -
The instance-of queries are right:
$.query.tier1isauth,gateway,ledgerandpayments;$.query.experimentalisnotify. -
bad/cycle.aon(ledger gains a callback into payments) refuses at evaluation with[aontu/relation_cycle], andaontu relationsanswersverdict: failnaming the loop:cycle $.catalog.domains.payments.services.ledger -> $.catalog.domains.payments.services.payments -> $.catalog.domains.payments.services.ledger. -
bad/wrong-target.aonwrites ahostedOnedge typedrel($.aontu.System.Service)that lands on akind: hostentity. The type flows into the target, so evaluation refuses with[aontu/scalar_value]andaontu relationsanswersverdict: error(exit 4) for a document that does not stand. -
aontu reachesanswers the closure question the edge-at-a-time checks cannot:gatewayreachesledger, and the chain is the evidence:$.catalog.domains.platform.services.gateway -> $.catalog.domains.payments.services.payments -> $.catalog.domains.payments.services.ledger. -
The direction is the relation’s rather than the graph’s. This model writes both
dependsOnand its inverse, so the whole edge set is symmetric and everything reaches everything; a directional question means naming the relation to follow. With--relation dependsOn, gateway still reaches ledger, and the reverse query answersverdict: unreachable(exit 1). -
An endpoint that names no entity is refused rather than answered no:
reacheson$.catalog.domains.platform.services.nopeanswersverdict: error(exit 4) with arefer_unresolvedfinding, since answering no would report a typo as a fact about the model. -
bad/missing-inverse.aon(email adds a directory lookup, nobody records the inverse) refuses at evaluation with[aontu/relation_inverse_missing], andaontu relationsnames the missing entry:$.catalog.domains.identity.services.directory does not list $.catalog.domains.platform.services.email under dependedOnBy. -
bad/tier-conflict.aon(an ops overlay claimingtier: 2for a service the catalog pins attier: 1) refuses to evaluate with[aontu/scalar_value]at the deploy position, which is what the deployment view’s reference is for. -
bad/wrong-kind.aon, a self-contained model with arefer($.aontu.System.Service)endpoint, refuses akind: databasetarget with[aontu/scalar_value]naming"database". -
vet --at '$.spec.CandidateShape'accepts the well-formed candidate:verdict: valid, exit 0. -
vet --at '$.spec.CandidateShape' --closedondata/candidate-malformed.jsonanswersverdict: invalid, exit 1, with[aontu/constraint]findings for the bad owner and the short description and an[aontu/closed]finding for the misspelledteirkey. Each finding carries the data position and, for a constraint, the schema position:$.spec.CandidateShape.owner: constraint [conflict] [aontu/constraint]: Cannot unify values at path $.spec.CandidateShape.owner expected: re("^team-[a-z]+$") actual: "platform crew" data: data/candidate-malformed.json:3:12 ("platform crew") schema: spec.aon:36:18 (re("^team-[a-z]+$")) $.teir: closed [conflict] [aontu/closed]: Cannot resolve value at path $.teir data: data/candidate-malformed.json:4:11 (2) -
proposals/onboard-webhooks.aonevaluates,aontu relationsstill answersverdict: passover catalog and proposal together, andgeton the newwebhooksentry matchesexpected/webhooks-proposal.json. -
proposals/onboard-badref.aon, whose candidate depends on a path no file writes, cannot evaluate:[aontu/rel_unresolved]. -
The catalog draws:
aontu view graphgrouped by owner andaontu view matrixin partition order both match their goldens, the matrix has no cell above the diagonal,--checkagainst the committed matrix passes, and the matrix as SVG matchesexpected/diagram-matrix.svg.
Running it
./check.sh, from anywhere; set AONTU= to point at another CLI
build. Every check prints an ok line, and the script stops at the
first failure.