Declare relationships
Relationships are how nodes interact — one service calls another, a service is deployed in a network, an actor uses a system. CALM 1.0 gives you four relationship types; picking the right one matters because ArchRails enforces different rules for each.
Anatomy of a relationship
Every relationship lives in the top-level relationships array of a CALM
file and has the same outer shape:
{
"unique-id": "payment-to-card-data",
"relationship-type": { /* one of: connects, interacts, composed-of, deployed-in */ },
"protocol": "https", // optional, mostly used with connects
"metadata": { "port": 443 } // optional, free-form annotations
}
The relationship-type body is what changes between the four types —
each has its own shape. Walk through each one below.
connects · service-to-service
The workhorse. Use connects any time one node makes a protocol-level call
to another — HTTP, gRPC, TCP, AMQP, anything wire-level.
{
"unique-id": "payment-to-card-data",
"relationship-type": {
"connects": {
"source": { "node": "payment-api" },
"destination": { "node": "card-data-service",
"interfaces": ["card-data-http"] }
}
},
"protocol": "https",
"metadata": { "port": 443 }
}
Two important details:
-
destination.interfacestargets a specific interface (unique-id) on the destination node. Ifcard-data-serviceexposes both an HTTP and a gRPC interface, this picks one. Same-node, different interface → second relationship. -
protocolis a free-form string, by convention lowercase:https,grpc,tcp,amqp,kafka,jdbc. Patterns (lesson 4) and controls (lesson 5) can match on it.
interacts · actor-to-system
Used when a human actor is involved. The shape differs from connects —
one actor on the left, a list of nodes on the right.
{
"unique-id": "trader-uses-web-gui",
"relationship-type": {
"interacts": {
"actor": "trader",
"nodes": ["web-gui"]
}
}
}
nodes is plural and accepts multiple targets — one
interacts relationship can express "the support agent uses both the CRM
and the case management tool." The validator fans these out into one edge per target.
composed-of · grouping
Used when one node is logically made up of other nodes — "the trading platform consists of these microservices." Helps the dashboard cluster related nodes and lets you write patterns that target a system without enumerating every child.
{
"unique-id": "trading-platform-composition",
"relationship-type": {
"composed-of": {
"container": "trading-platform",
"nodes": ["orders-server", "risk-engine", "market-data"]
}
}
}
composed-of parents — the model becomes ambiguous
("which platform owns this?"). If a service genuinely belongs to two systems, you
probably want to model it as two services with a connects between them.
deployed-in · runtime topology
Used when one node lives inside another at runtime — a service running
in a Kubernetes cluster, a database in a VPC, a Lambda in an AWS account. The
container is typically a network or system; the deployed
children are services and databases.
{
"unique-id": "prod-vpc-deployment",
"relationship-type": {
"deployed-in": {
"container": "prod-vpc",
"nodes": ["orders-server", "orders-db", "orders-cache"]
}
}
}
Same one-container-per-node rule applies. ArchRails will flag a service declared as deployed in two different containers — runtime topology has to be unambiguous for controls (e.g., "must be deployed in a PCI-scoped VPC") to make sense.
Picking the right type
A 30-second decision tree:
Is one end of the relationship a person? → interacts Is the relationship "lives inside at runtime"? → deployed-in Is the relationship "is logically made up of"? → composed-of Otherwise — it's a wire-level call between two systems → connects
Common mistakes:
-
Modeling "the team that owns this service" with
interacts. Ownership isn't an interaction — use a non-CALM mechanism (CODEOWNERS, repo metadata) for that. -
Using
connectsto express "Service A and Service B both live in cluster X." That'sdeployed-in, twice — one per service. -
Using
composed-ofwheredeployed-infits better.composed-ofis a logical grouping ("the payments platform");deployed-inis a physical grouping ("the prod cluster"). A service can be in both: composed-ofpayments-platformAND deployed-inprod-cluster.
Cross-repo relationships
Most production setups have services in different repos. ArchRails resolves cross-repo references via federation — each repo bootstraps its own CALM file, and the federated graph stitches them together at the boundary.
Two valid ways to mark a reference as cross-repo. Both work; the resolver checks both and endpoint-level wins when both are present (more specific, and aligned with the FINOS contribution).
Endpoint-level (recommended): declare the repo directly on the relationship endpoint. The foreign node doesn't need to be declared locally — the endpoint says everything the resolver needs.
{
"unique-id": "web-gui-to-account-service",
"relationship-type": {
"connects": {
"source": { "node": "web-gui" },
"destination": {
"node": "account-service",
"repository": "https://github.com/myorg/accounts-server"
}
}
}
}
This is the ArchRails-proposed FINOS extension on the relationship-endpoint shape. Two things to like about it: it keeps the cross-repo info next to the edge that uses it (no need to redeclare the foreign node), and it's the natural fit for "contract" repos that exist purely to declare cross-service edges.
Node-level (still supported — monorepo legacy convention):
mark the foreign node's repo on the node spec itself, using
metadata.repository:
// In the frontend repo's CALM doc — declaring a node that lives elsewhere: { "unique-id": "account-service", "node-type": "service", "name": "Account Service", "description": "Authoritative account ledger. Lives in accounts-server repo.", "metadata": { "repository": "https://github.com/myorg/accounts-server" } }
Useful when you've already got the foreign node declared locally for other reasons
(you wanted its name and description to surface in the
visualizer, for example). The relationship pointing at this node looks like any
other connects — no extra field needed on the edge.
What ArchRails enforces
At PR time, ArchRails uses your declared relationships as the allow-list for what the code is permitted to do:
-
connects— if a diff introduces a call from Service A to Service B and there's noconnectsdeclaring it, the PR is flagged. Same for new protocols (e.g., introducing AMQP between two services that only declared HTTPS). -
interacts— mostly documentation today, surfaced in the graph view. Not enforced at PR time because actor behavior isn't visible in code diffs. -
composed-of— sibling nodes under the same parent are expected to interact through the parent, not directly. Direct sibling-to-sibling calls without an explicit relationship are flagged. -
deployed-in— one container per node. Conflicting deployments are flagged. Patterns (lesson 4) can require specific deployments ("PCI-scoped services must be deployed in a PCI-scoped VPC").