Lesson 2 of 6 · 6 min read · Foundations

Declare nodes

Nodes are the things in your architecture — services, databases, actors, networks. Every CALM node lives in a *.calm.json file under architecture/ and (if it represents code in your repo) is bound to a directory by a mapping entry in .archrails/config.yml. This lesson covers the node catalog, the required fields, and the path-aligned ID convention that makes the binding work.

In this lesson
  1. Anatomy of a node
  2. The node-type catalog
  3. Required fields
  4. Why IDs are path-aligned
  5. Mapping the node to a directory
  6. Interfaces — hosts, ports, protocols
  7. Worked example · add a Redis cache
  8. Common mistakes

Anatomy of a node

A node is an object inside the top-level nodes array of any CALM file. The minimum-viable node looks like this:

{
  "unique-id":   "orders-server",
  "node-type":   "service",
  "name":        "Orders Server",
  "description": "Accepts new orders from customer-portal and persists them to orders-db."
}

Four fields, four jobs: unique-id is the stable handle every other CALM object (relationships, flows, controls) uses to refer to this node; node-type tells the validator what kind of thing it is; name is the human-readable label shown in dashboards; description is your one-sentence answer to "what is this and why does it exist?"

The node-type catalog

CALM 1.0 ships a small fixed catalog of node types. Pick the most specific one that fits; if nothing fits cleanly, use system and revisit later.

service          A running process you ship. Most code nodes are services.
database         Any persistent data store — Postgres, Mongo, Redis,
                 DynamoDB, S3 bucket used as a store.
network          A network boundary — VPC, subnet, k8s cluster, region.
                 Other nodes are deployed-in these.
actor            A human role — customer, support agent, compliance officer.
                 Actors only interact with other nodes.
system           A coarse grouping — "the trading platform", "the CRM".
                 Use sparingly; prefer breaking down into services.
ldap             A directory service — Active Directory, Okta, Auth0.
data-asset       A logical data set (not a store) — "customer PII",
                 "settlement records". Useful for data-lineage flows.
webclient        A browser-resident UI — SPA, mobile web app.
Why not just call everything a service? ArchRails enforces different rules per type. A database can't talk to another database (only services can connect to databases). An actor is barred from connects relationships (they interact with systems instead). Picking the right type is how you get the right validation for free.

Required fields

The CALM v1 schema requires four fields on every node:

Optional fields you'll reach for next: interfaces (host/port specs), controls (compliance attachments — see lesson 5), and repository (only when this node lives in a different repo — see lesson 3).

Why IDs are path-aligned

ArchRails binds CALM nodes to directories via the mapping block in .archrails/config.yml. If your node's unique-id matches its top-level directory name, the binding is trivial and bootstrap inference catches it with high confidence. If they diverge, you have to write the mapping by hand and re-read it every time you onboard a new teammate.

Recommended:

# directory                    # node unique-id     # confidence
customer-portal/                customer-portal      high ✓
orders-server/                  orders-server        high ✓
services/payment-api/           payment-api          high ✓

Avoid:

# directory                    # node unique-id     # confidence
customer-portal/                cust-front           REVIEW REQUIRED ✗
backend-v2/                     orders-service       REVIEW REQUIRED ✗
src/                            payment-api          conflict ✗

The first two confuse bootstrap inference; the third is the worst case — a single src/ shared by three services means none of them can claim it without misattributing the others' files during PR validation.

Mapping the node to a directory

The CALM file says what exists; .archrails/config.yml says which files belong to which node. After declaring a node in CALM, add a mapping entry:

mapping:
  - path: "orders-server"
    node: orders-server
  - path: "services/payment-api/**"
    node: payment-api

Both prefix paths and glob patterns work. Use a glob when the service's code is split across siblings (payment-api-core/, payment-api-grpc/) and you want one node to own all of it.

Infrastructure-only or external nodes get no mapping. A database node representing a managed RDS instance has no code in your repo — leave the mapping commented out. Same for actor and external system nodes. Bootstrap emits these as commented placeholders; keep them that way.

Interfaces — hosts, ports, protocols

An interface declares how something connects to this node. Most services have one, databases have one, and the most common shape is host-port-interface:

{
  "unique-id":   "orders-server",
  "node-type":   "service",
  "name":        "Orders Server",
  "description": "Accepts new orders from customer-portal.",
  "interfaces": [
    {
      "unique-id": "orders-http",
      "type":      "host-port-interface",
      "host":      "orders-server.internal",
      "port":      443
    }
  ]
}

Relationships then target a specific interface by unique-id, so when a service exposes both an HTTP port and a gRPC port, callers pick the right one. The full interface vocabulary (oauth2-audience, rate-limit-interface, plus JSON-Schema-backed custom types) is in the FINOS CALM spec — the host/port form covers ~80% of real-world cases.

Worked example · add a Redis cache

Suppose orders-server just got a Redis cache for hot order lookups, and we want it under governance. Two edits.

1. Declare the node in architecture/system.calm.json:

{
  "unique-id":   "orders-cache",
  "node-type":   "database",
  "name":        "Orders Cache",
  "description": "Redis cache fronting orders-db for hot lookups. TTL 5m.",
  "interfaces": [
    {
      "unique-id": "orders-cache-redis",
      "type":      "host-port-interface",
      "host":      "orders-cache.internal",
      "port":      6379
    }
  ]
}

2. Skip the mapping — Redis is managed infrastructure with no code in this repo. No mapping entry needed. (Compare with orders-server, which is in this repo, so it does get a mapping.)

That's the node. To make it part of the architecture, you'll also need a connects relationship from orders-server to the Redis interface — covered in lesson 3.

Common mistakes

I used id instead of unique-id and validation fails.

Easy one. The CALM spec uses unique-id (kebab-case, hyphenated) on every object — nodes, interfaces, relationships, flows. Tools like the FINOS CALM CLI will surface this as a schema error pointing at the offending object.

My node has no top-level directory — it's spread across src/.

Two options. Best: reorganize so each service has its own directory under the repo root — this also makes ownership, CI, and code review cleaner. Acceptable: use a glob mapping like "src/orders/**" and live with the fact that your other services need equally specific globs to avoid overlap.

I picked system but the dashboard says “no validation rules apply.”

system is a deliberately loose type — CALM doesn't know enough about it to enforce much. If the thing you're modeling is actually a service or a database, change the node-type and rerun validation. Use system only for genuine coarse groupings like "the trading platform" that contain other nodes.

Bootstrap put a REVIEW REQUIRED comment in my mapping block. What now?

Bootstrap couldn't match the node to a directory. Three resolutions, in order of how often they apply:

  • Infrastructure or external: the node has no code in this repo (RDS, S3, third-party API, human actor). Leave the comment block in place as documentation that the node was considered and intentionally left unmapped.
  • Misnamed: the directory and the node ID don't align. Rename the node ID to match the directory (preferred) or add the mapping by hand.
  • Genuinely missing: the node was declared but the code hasn't been written yet. Same fix — mapping by hand — or remove the node from CALM until the code exists.