The compiled graph

A graph of nodes connected by @id pointers — the Bare Four gave every node a stable identity to point at — raises an obvious question: when a page needs a Place and all of its reviews, who follows those pointers, and when? Most systems follow them at request time — a query, a join, a cache layer to make the join bearable. CTXR follows them once, at write time, and never again.

Write-time resolution

When an editor approves content, the compiler resolves every @id stub into the full referenced node. The draft holds pointers; the live record holds the resolved result.

Draft:  { "itemReviewed": {"@id": "ctxr:a1b2c3"} }
                              │ approve → compile
                              ▼
Live:   { "itemReviewed": {"@type": "Place", "name": "Fikkie", "geo": {…}} }

The result is a compiled graph: a self-contained document that already holds everything a page needs to render. A Place page has its reviews inlined. A route page has its stops, each with their coordinates, already resolved. A template opens one JSON file and is done — zero runtime lookups, no joins, no second query.

This is the key move. The cost of resolving references is paid once, when the editor clicks approve — not on every page view by every visitor.

Three kinds of reference

Not every reference behaves the same way, and the differences are what keep the graph both complete and bounded. CTXR recognises three.

Compose — a parent contains a child. A route composes its stops; an overview page composes the cards it curates. Composed children are embedded in full in the parent’s compiled record: the parent owns them. This is the downward, “this is part of me” relationship.

Aggregate — a child names a parent, and the parent gathers those children back. A Review names the Place it reviews; the Place aggregates its reviews. There can be thousands, so the parent doesn’t embed them all — it bakes in a bounded array of the most recent, plus a derived summary. The inbound lands on the natural inverse property: a Place ends up with a review[] and an aggregateRating it never authored.

Link — a node refers to another without owning it or aggregating from it. A Review’s itemReviewed points at its Place; a “related place” points sideways. A link resolves to a small identity projection — just enough of the target to show it (its name, its image, its address) — not the whole node. It is a pointer that renders, not a copy.

What is compiled, and what is queried

Behind those three kinds sits one architectural line: the compiler bakes only what is schema-true of a node as a single entity — its own properties, its composed children, and its direct inbound. Everything beyond that — anything transitive or cross-hierarchy — is queried: computed live at request time, never baked.

So a Place’s own reviews are compiled into it. But “all the reviews of every place in this region” is a cross-node view — a live query, rendered into the page, never claimed as the Place’s own review[]. Same data shape, two different provenances. Keeping them apart is what lets the compiled JSON-LD stay honest for a search engine (the aggregateRating matches the review[] actually on the node) while richer roll-ups still appear in the HTML. It is also why CTXR has three kinds of list — curated (compose), relational (aggregate) and queried (live) — covered in Nodes & Relations.

Deriving values

Compiling isn’t only inlining; it is also deriving. When the compiler aggregates a node’s inbound, it computes summary values no editor ever typed: the review[] array, the aggregateRating (a count and an average), a containment count. These derived fields are compiler output, not authored data — recomputed on every cascade, never stored as though a person had written them. The live record carries them so a visitor, a search engine and a language model all read the same already-summarised graph.

Semantics drives the mechanics

Here is the part that makes the whole thing cohere. The compiler carries no hardcoded knowledge that “Places have reviews” or “routes have stops.” It reads the semantics of the schema — for each property: what types it accepts (its range), which property is its inverse, whether it composes, aggregates or links, and whether it holds one value or many — and acts on those facts generically. Polymorphic ranges, inbound roll-ups, cardinality limits, embed-versus-project: each becomes an instance of one general rule rather than a special case in code.

So there are really two halves. A semantic engine declares what the schema means — drawn straight from Schema.org, because the vocabulary already exists; we reflect it, we don’t invent it. A mechanic engine executes: it compiles, derives and cascades by reading that meaning. A declaration is true without an executor; an executor is meaningless without a declaration — so the dependency points inward, semantics before mechanics. The concept is enough here; how the two engines are actually built is the subject of Use of semantics and The mechanical engine in the Development chapter.

Depth keeps the graph acyclic

References have a direction, and that direction is governed by depth. Every type sits at a level: a page or a Place is shallow (depth 0); a Review, a list item or a menu entry sits a level down (depth 1); an image or a person sits deeper still (depth 2).

The rule is simple: a node may only reference nodes below its own depth. A page can contain a Place, a Place can carry an image — but an image can’t point back up at the page. That single constraint guarantees the graph has no cycles, which is what makes write-time compilation terminate: the compiler can resolve and cascade without ever chasing its own tail. The rule is enforced in more than one place — when content is created, when it’s saved, and when it’s compiled — so a malformed reference is caught rather than quietly corrupting the graph.

The cascade

A compiled document is a snapshot, so when source data changes, the snapshots that include it must be rebuilt. The compiler does this automatically through a cascade.

When you approve a Place, every page that composes that Place gets recompiled with the new data. When you approve a Review, the Place it names gets its aggregate rating recalculated. The editor clicks approve once; the platform updates everything that depended on it.

The cascade follows a topology index — a persistent map of which nodes reference which, and of what kind each link is. It works bottom-up: the changed node first, then its composing parents, then theirs, stopping as soon as it reaches nodes that don’t reference the changed content. Recompiling a node touches only its local ancestors, not the whole space — so the work scales with how many places a node actually appears in, not with the total size of the site.

A small worked example. Imagine a home page and a walking route that both feature the same Place, which in turn has reviews:

WebPage (home) ──composes──▶ Place (fikkie) ◀──aggregates── Review
TouristTrip (route) ──composes──▶ ListItem ──links──▶ Place (fikkie)

Approving an edit to fikkie recompiles the home page and the route, because both compose it — but it does not recompile the reviews, which only name it. Approving a new review, by contrast, recomputes fikkie’s aggregate rating and cascades from there. The topology index is what lets the platform know, in each case, exactly which documents to rebuild and which to leave alone.

What you get

The compiled graph is what makes CTXR fast without bolt-on caching layers, CDNs or optimization tricks. The graph is always complete, always pre-resolved, always ready to serve. The system reaches consistency at the level of the filesystem: after a cascade settles, every live document on disk is correct.

The trade — and it’s a deliberate one — is that writes do more work than reads. That’s exactly the bargain we want, and Cheap vs expensive: who pays explains why.