The mesh & links
The mesh is the whole interlinked set of pages plus the link graph between them. The crucial thing: those links are derived from the committed graph, not hand-placed and not stored on the page body.
What it is
Every page sits somewhere in an authority topology — a pillar, hub, sub-hub, or spoke: its role, which the engine derives from the topical edges rather than storing as a column. From that topology GoToMesh computes, for each page, four things:
- up-links — the parent hub(s) this page funnels into,
- sibling links — peer pages on a shared domain axis,
- breadcrumbs — the path from an apex hub down to the page,
- redirects — old URLs that resolve to the current slug.
All of it is derived. The page body never contains a link to another page; the link graph lives in the committed graph (as typed edges) beside everything else.
Reading this graph from an SSG? This page is the engine’s derivation of the
links. For how a site build consumes them — the per-view manifest blocks
(live vs preview), the four scopes (up / down / siblings / across), the derived
down edge (hubMembers), hub members vs. facets — see
Navigation & relationships.
Why it matters
Keeping links out of the page body is what makes the mesh cheap to maintain. When a link edge moves — a new sibling appears, a hub is added — the engine just re-derives site.manifest.json and the site re-renders. That is a zero-rewrite manifest re-projection: no writer, no prose rewrite, no page file even touched. Only a real content change pulls the expensive writer step back in.
Mesh links live in the committed graph, so there is no second copy to drift. Re-derivation happens on every change, automatically and deterministically — the manifest is always consistent with the graph.
Choosing a role
A page’s role is its job in the link graph — and it is derived from the graph, not stamped. A page is a pillar because it has an outgoing PILLAR_OF edge to a topic, a hub because it has a HUB_OF edge (a sub-hub if that hub is itself a member of a higher owned topic), and a spoke otherwise. So authoring the apex edge IS the promotion — the moment classify (from a signals.csv rule or a --role) or gtmesh graph link … PILLAR_OF writes the edge, the role follows; there is no separate stamp to keep in sync (the stale-stamp bug the old role column caused). role is a view of the edges, exactly like clusters.
The distinction that trips people up is pillar vs hub — informational vs navigational:
| Role | Its job | Pick it for | Example |
|---|---|---|---|
| pillar | The informational convergence vertex — the deep “what is X” page that owns the head-term. Spokes’ contextual weight concentrates here. | The definitional/explainer page a topic’s supporting content should point into. | /guides/how-does-a-rotary-lobe-pump-work |
| hub | The navigational / commercial centre — the catalogue or conversion page breadcrumbs run through. An up-link target, but it stays the archive layer and doesn’t hoard in-content weight. | Category/brand/section landing pages; the money page. | /categories/rotary-lobe-pumps |
| sub-hub | An intermediate hub beneath the apex. | A hub that sits under a higher hub (a manufacturer hub under a /manufacturers index). | /manufacturers/alfa-laval |
| spoke | A supporting leaf that links up. | Everything else — products, individual guides, comparisons. | /products/alfa-laval-sru |
When a topic has both a pillar and a hub, they are CO-APEXES — an informational spoke prefers the pillar, a commercial spoke reaches both. Direction comes from the topical graph itself: a member routes up to its topic’s apex owner, which is above it by construction, so an authored or owned pillar is always reachable. An informational spoke therefore reaches the pillar even when a commercial hub owns the same topic — the explainer collects the contextual weight while the catalogue stays the breadcrumb/commercial layer. A topic with no pillar behaves the same way with one apex — its spokes converge on the hub.
Pillar and hub are two jobs at the same level — not a chain. A pillar doesn’t links_up into the commercial hub; it’s the top of its informational cluster. The pillar → catalogue relationship is lateral (a sibling/across link that passes topical authority to the money page), not an up-link — it belongs in the across rail, not the parent chain. And keep your commercial spokes (products) routing to the hub via hub_of(category), not the informational pillar — otherwise buy-intent pages ladder into an explainer. When in doubt, set the role, run gtmesh plan, and read the resolved links_up arrays.
You appoint roles two ways: reference/signals.csv (keyword pattern → role, for demand-derived pages) or an explicit --role on gtmesh page add (for source-less pages — e.g. --role pillar on a decreed explainer). Either way the appointment is an ownership decision that makes classify emit the PILLAR_OF/HUB_OF edge; the role you read back is derived from that edge. See Tuning.
Curated content clusters (author a pillar + hand-picked spokes)
Most topics form automatically: a page’s entity axes (category/manufacturer/facets) and its
intent-derived theme topic put it in the right clusters with no hand-work. But sometimes you want the
classic pillar-and-spoke cluster over pages that share no entity — e.g. pool a set of selection
glossary terms (duty-point, npsh, pump-curve, …) onto a broad “how to select a pump” guide so
that content builds authority on the pillar instead of each term routing flat to its section index.
Because topics are data, they live in the graph — you author the cluster directly:
gtmesh graph upsert topics/pump-selection --label topic --prop name="Pump Selection"
gtmesh graph link guides/how-to-select-a-pump PILLAR_OF topics/pump-selection # the pillar
gtmesh graph link glossary/npsh MEMBER_OF topics/pump-selection # a spoke
gtmesh graph link glossary/duty-point MEMBER_OF topics/pump-selection # …one per spoke
gtmesh apply --yes && gtmesh buildCLI-authored nodes and edges default to provenance: authored (an appointment — override with
--prop provenance=…). That matters: apply re-derives the entity/intent topical layer from your data
every run, and it carries authored curated topics + memberships forward across that rebuild (the same
way it preserves redirects), so a hand-authored cluster survives the build loop instead of being
wiped. The spokes then resolve up to the pillar in the manifest — and because the appointment is
explicit, an authored PILLAR_OF pools its spokes even when the pillar sits at the same level as its
spokes (a guide and its glossary terms are all leaf content) — direction comes from the topical edge
itself. Remove a spoke’s page and its authored membership drops with it (no dangling edge).
Use it deliberately — a curated cluster is a real editorial decision, not something the engine guesses.
For prose cross-links inside the cluster, keep using ref:page/<id> (see symbolic
refs); the curated topic is what makes those links show up in the
structural up-link graph too.
Structural pages — home & section indexes
The home page (/) and a section index per section (/manufacturers, /categories) are pages the mesh should own — so their metadata (and later, their featured/member lists) is a content edit in the mesh, not something hand-scaffolded in the SSG.
- The home page is minted by default — a single
content/index.yaml, page typehome. Every site has one, so as long as your config declares thehomepage type (the scaffold does), the engine derives it; delete that page type to opt out. It’s the site’s nav root, not a cluster vertex, so it carries no cluster role; you author its meta (and later a hero + featured projections). - Section indexes are the explicit opt-in — set
index_pages:gtmesh config set index_pages truemints one pertaxonomy.section, orgtmesh config set index_pages '["products","categories"]'mints only the named sections. That list form decouples “is this a routable section” from “does it get an archive page” — a section can exist for classification/strategy without shipping a dead index. Auto-minting an archive for every section is a real choice, so it stays off until you ask. Their meta is authored in the scaffoldedcontent/<section>/index.yaml; their member list is derived from the mesh (never hand-listed). A section root that already has a page (a decreed apex) is left alone.
A section index carries section: <s>, so it is in its section — but it’s the section’s root, not a member of itself. loadPagesBySection excludes it (and the home) by default, so an index never self-lists; routing helpers (pageSlugs) still emit its route. (The eventual index→members parent/child modelling makes this a proper graph edge.)
Both are structural — the article-writer never drafts them (the operator authors the meta); different sections can use different index templates by declaring per-section index page types. To give a section index a members list or the home a featured grid, reference a projection from the bundle — so “featured products” stays a content edit.
How it works
You declare the link policy once, under link_rules — appended with gtmesh config add, which is comment-preserving and re-validates the file on every write. Each rule is a when → then against a page’s type, role, or entity, and sets one or more of:
| Rule field | What it does |
|---|---|
up | The parent a page links up to — e.g. hub_of(entity), or nearest_hub_in_authority_flow |
up_by_subtype | Sub-type-keyed parents (e.g. a brand_vs_brand comparison links up to both brand hubs — a dual parent) |
siblings | The peer axis — a cluster (same: topic) or a reference-table column, plus a count |
exclude_as_target | true ⇒ pages this rule matches are never a siblings/across target — a link distributor, below |
Rules are first-match-wins per field, so specific rules (by page type or entity) must sit above the generic role: spoke fallback or they get shadowed — gtmesh config add link_rules … --before '"spoke"' places one where it belongs.
Link distributors (the glossary rule)
Some pages should feed the mesh without ever receiving in-content weight. The glossary is the canonical case: each entry links up to its pillar and down to the deep page for its term, but pouring in-content links into a shallow glossary strands that weight. Mark such pages with exclude_as_target: true and the engine drops them from every siblings/across target set — while leaving their own out-links (up/down/across as a source) untouched:
# `for` is a when-clause — name your own distributor section/role.
# `exclude_as_target` ⇒ never a siblings/across TARGET; the page still links out.
gtmesh config add link_rules '{"for":{"section":"glossary"},"exclude_as_target":true}'It’s opt-in and inert until you add the rule. On a mesh built before you added it, gtmesh doctor’s excluded-target-inbound check flags any distributor still receiving inbound edges — re-plan + apply to clear them. The graph is then pruned per environment: each environment in gtmesh.config.yaml environments declares which statuses it renders, so a dev build only links among review/published pages, while prod links only the live set.
How siblings are chosen (the default rule)
Under the graph model’s default rule, a page’s siblings are its most topically-related peers in the same section — not a fixed grouping. “Related” is a real overlap metric: how many topics two pages share (a page’s topics are its cluster-axis values + its entity). The algorithm, per page:
- Candidates — every other page in the same
section(products with products, guides with guides), excluding itself and anybacklogpage. - Relevance — score each candidate by the topics it shares with the page, weighted:
relevance = Σ links.weights.by_type[topic.kind]over the shared topics (default weight1, so every shared topic counts). More shared topics ⇒ more relevant. - Keep only candidates that share at least one topic.
- Rank by relevance ↓, then the node
weightoverride, thentotal_vol, thenslug. - Cap at
links.siblings.limit(default 3), and dedup — a page that’s already an up-link oracrosstarget won’t also be listed as a sibling.
Worked example — a product. products/alfa-laval-sru has topics {rotary-lobe-pump, alfa-laval, use-case} (its category, manufacturer, and intent topic). Its siblings are the top-3 other products that overlap most: another Alfa-Laval rotary-lobe-pump shares two topics (category + manufacturer), so it outranks a product that shares only the category. Ties break by volume, then URL.
Cross-section peers are across, not siblings (e.g. a glossary entry ↔ its guide). Siblings never cross a section; across exists to cross it.
How across links are chosen (the default rule)
A page’s across links are the other page types’ treatments of the topics it belongs to — a product surfaces the guide, glossary entry, and comparison covering its category; a hub surfaces the editorial coverage of its own topic. Resolved over the same topical edges as every other link class, per page:
- Candidates — every page of a different
page_type(any section), excluding itself and anybacklogpage. - Qualify — asymmetrically, on purpose. The source qualifies through any of its topic memberships. A target counts only for a topic it primarily treats: the topic it owns as apex, or the membership its own
parent_topicconfers. A page that is merely a co-member of a shared facet topic is not swept in —acrossmeans same subject, different treatment, not topically adjacent. - Rank by
relevance = Σ links.weights.by_type[topic.kind]over the qualifying shared topics (default weight1), thenpriority, thentotal_vol, thenslug— so withcategoryweighted aboveindustry, the category’s guide outranks an industry’s. - Cap at
links.across.limit(default 3), and dedup — a target already inupnever repeats inacross(a cap-trimmed apex, though, can resurface here: the relationship demotes to the lateral rail instead of vanishing).
A term nothing owns is not a topic, so it never produces an across link — appoint an apex (elevate the value to a topic) to activate the lateral.
Tune both rails with gtmesh config set — links.siblings.limit / links.across.limit (how many) and links.weights.by_type (which shared topics count most):
gtmesh config set links.siblings.limit 6
gtmesh config set links.across.limit 3The SSG reads a page’s siblings from the manifest (loadManifest().pages[slug].siblings), and a page’s topics from the graph (loadGraph().edgesOf(id, "MEMBER_OF"), with membersOf("topics/<value>") for the reverse — every page in a topic).
How up-links resolve
The up field says what kind of parent a page wants; the engine resolves it to an actual page deterministically. The value that trips people up is up: hub_of(entity) — “link up to my entity’s hub.” A page links to hub_of(its own resolved entity) — the best-ranked hub whose entity is this page’s entity, sitting strictly above it in the authority flow — and if there’s no such hub, it falls back to the nearest hub above, and ultimately the apex.
A shared axis is not enough to reach another entity’s hub. A cross-entity guard stops a page that resolves to entity X from ever laddering up to entity Y’s hub, even when they share a domain axis. A product reaches its category hub only because it resolves to the category entity (it carries that category’s value) — not because the two merely sit on the same axis.
The full resolution order for an explicit up rule (the link_rules model — see also the default rule below), once the candidate pool is restricted to hubs strictly above the page in the authority flow:
- Same-entity hub — a hub whose
entityequals the page’s resolved entity. This ishub_of(entity). - Shared-cluster landing — failing that, an entity-less hub (a generic category/topic landing) carrying the page’s value on a shared axis. The guard permits laddering to a landing that belongs to no entity, but never to a different entity’s hub.
- The apex — failing both, the nearest hub above (the convergence page).
So a shared cluster does form an up-link — but only to an entity-less landing, never to a rival entity’s hub. That distinction is the whole subtlety.
How a product reaches its category hub, concretely. Entity resolution gives a product membership on every domain axis it carries a prop for — a product entity node with a category and a brand prop carries both clusters.category and clusters.brand. A rule like up: [hub_of(category), hub_of(brand)] routes each axis separately: category → the entity-less category landing that carries that value; brand → the brand hub whose entity slug equals the brand value (a dual parent). The product doesn’t become the category entity — it holds the category’s cluster value, which the category landing owns. (Which entity a keyword resolves to in the first place is set upstream by classification.entity_kind_priority — highest class wins when several match; see Tuning §2.)
Diagnosing a missing up-link. If a page links nowhere or to the wrong parent, run gtmesh graph get <id> and read its entity and clusters. No entity ⇒ it can only reach an entity-less landing or the apex — usually a missing entity or alias. Wrong parent ⇒ check entity_kind_priority picked the entity you expected.
The default up-routing rule
The hand-written up: … tokens above are the explicit model (link_rules). By default, up-links come from the default rule — the same topic-union that already governs siblings, applied to parents:
- A page up-links to the apex of every topic it belongs to (its facet topics + its entity topic). An informational page prefers each topic’s pillar; a commercial page takes each topic’s hub (and pillar). No per-page
uptoken to author — the memberships are the routing. - Direction is the topical graph itself. A member routes up to its topic’s apex owner, which is above it by construction — so an authored or owned pillar is always reachable. (A topic with a commercial hub and an informational pillar as co-apexes lets an informational spoke reach the pillar over the hub.)
roleis not stored: it is a view of the apex edges. - Parents are weight-ordered by
links.weights.by_type(a topic’s kind → weight; higher = the primary parent, resolved first), then volume, then URL. - Capped by
links.up:total_limitcaps the total number of parents;default_limitcaps how many per axis (the rulebook’s “link up to your 1–2 pillars, not every hub you touch”);axis_limitsoverrides a specific axis. Caps apply after weight ordering, so the highest-weight parents survive. - An apex edge implies membership. A page that is
HUB_OF/PILLAR_OFa topic is a member of it, so the graph stores just the one apex edge — no redundant self-MEMBER_OF. Every reader (clusters/facets reconstruction, sibling relevance,explain) treats an apex edge as conferring membership, so a hub still carries its own cluster value and siblings with its peers. (This is why the curated-cluster example above authors onlyPILLAR_OFfor the pillar — the membership is implied.)
So a product that is a member of category, manufacturer, and industry topics, with by_type: { category: 4, manufacturer: 3, industry: 2 } and up: { total_limit: 3, default_limit: 1 }, resolves category → manufacturer → industry (one per axis, top three). This is set once in config, not per page. The scaffold ships sensible defaults; tune links.weights.by_type to your entity axes.
Sub-hubs, apexes & members — promoting / demoting authority
Choosing a role above covers what the roles are; this is the operations view — how a layered mesh maps onto the graph, and how you actually change or diagnose a page’s authority.
The classic ladder people picture — a lead pillar, feeder sub-hubs beneath it, leaf spokes at the bottom — is two graph relationships: a topic’s apex (HUB_OF/PILLAR_OF) and its members (MEMBER_OF). So a “sub-hub that funnels up to the pillar” is just a member: it routes up to the topic’s apex like any other member. A per-entity model listing (/modeli/<entity>) that feeds the entity’s hub is a member of the entity topic, not a second apex on it. Two apexes on one topic is a co-apex (both are top-level owners), which is almost never what a “sub-hub” means.
To make a page a genuine sub-hub — one with its own authority pool, not just a member — give it its own narrower topic and make its children members of that:
# /modeli/claude hubs a "claude models" topic that is itself a member of topics/claude
gtmesh graph upsert topics/claude-modeli --label topic
gtmesh graph link /modeli/claude HUB_OF topics/claude-modeli
gtmesh graph link /modeli/claude MEMBER_OF topics/claude # the sub-hub still routes up
gtmesh graph link /modeli/claude-opus MEMBER_OF topics/claude-modeliOnly worth it if the narrower set deserves its own pool; otherwise members-routing-to-the-pillar is simpler and usually right.
Changing a page’s authority role (promote to apex / demote to member) is not the gtmesh page promote/demote verbs — those move a page along the lifecycle (status), never its authority. Authority role is driven by the apex edge:
- Via classification: appoint the page’s
role— areference/signals.csvrule, or--role hub/pillarongtmesh page add.role: hub/pillar⇒classifyemits an apex edge (HUB_OF/PILLAR_OF) for the value it owns; a blank/spokerole ⇒ a member. (Demoting an accidental co-apex is exactly this: clear thesub-hub/hubrole so the page classifies as a member.) - Directly on the graph: edit the committed graph —
gtmesh graph link <page> HUB_OF|PILLAR_OF <topic>to promote,gtmesh graph unlink <page> HUB_OF <topic>(+graph link <page> MEMBER_OF <topic>if it should stay a member) to demote. These persist — the graph is the state.
Spotting an accidental co-apex. If a page you expect to be a member carries no up-link and its would-be siblings suddenly route to it, it’s likely an unintended apex. gtmesh graph get <id> and look for a HUB_OF/PILLAR_OF edge on a page that should only be MEMBER_OF — a stray hub/sub-hub role (seed or signal) is the usual cause.
Catalog vs content pages: the HAS_<axis> gate
The graph carries two kinds of membership edge, and telling them apart is what keeps a guide from masquerading as a catalog page:
HAS_<axis>— the domain substrate. An authored edge that says “this page embodies an entity on this axis” — a productHAS_CATEGORY,HAS_MANUFACTURER. This is the money layer: it means the page is a catalog entry for that category/brand.MEMBER_OF topic— the topical layer. A derived edge that says “this page is about this topic.” It’s what routes a page up to the topic’s apex (pillar/hub).
The catch: a content page can pick up a domain axis just by mentioning an entity. A guide titled “how does a booster pump work” keyword-matches the booster-pump category entity and inherits clusters.category=booster-pump — which, left ungated, would emit a spurious HAS_CATEGORY and file the guide alongside the actual product catalog.
The catalog flag on a page type fixes this. Mark the types that genuinely embody an entity — the scaffold ships catalog: true on product and entity-hub; add it to any project-specific hub type (category-hub, manufacturer-hub, …):
page_types:
- id: product
when: { section: products }
catalog: true # embodies an entity → emits HAS_<axis> domain edges
template: product.md
schema: product.schema.yaml
- id: guide # only MENTIONS entities → no catalog: → MEMBER_OF only
when: { section: guides }
template: guide.md
schema: guide.schema.yamlNow only catalog page types emit HAS_<axis>. The guide keeps its MEMBER_OF topics/booster-pump, so it still routes up to the category via the topic apex — it just stops claiming to be a catalog entry. The flag is opt-in: a mesh that declares no catalog page type keeps the legacy emit-for-all behavior unchanged, so nothing breaks until you adopt it.
Key files & flags
| Thing | What it is |
|---|---|
gtmesh.config.yaml link_rules | The explicit up / up_by_subtype / siblings / exclude_as_target policy |
gtmesh.config.yaml links.weights.by_type | Topic-kind weights driving up-order + sibling relevance (the default rule) |
gtmesh.config.yaml links.up | total_limit / default_limit / axis_limits — the up-link caps |
reference/signals.csv | Keyword pattern → role — the ownership appointment classify reads: an apex role (hub/pillar) emits that page’s HUB_OF/PILLAR_OF edge and mints the topic it owns |
gtmesh.config.yaml environments | Which statuses each environment renders (prunes the graph) |
| Redirects | url/ALIAS_OF graph nodes — projected into site.manifest.json; old→current, the SSG emits 301s |
gtmesh render-manifest --env <env> | The renderable slug set for an environment |
Upgrading an existing mesh
A mesh built before roles were derived carries a stored role on every page. Run gtmesh upgrade to bring the config to the current shape, then gtmesh apply: the committed nodes shed the stale stamp and the role is re-derived from the apex edges. Up-link routing is unchanged — an owned or authored pillar is reachable from every member of its topic.
upgrade also narrows any rule that qualified on a page’s authority band. A match clause is a free-form record, so a clause still naming tier would parse and quietly match nothing; the migration strips it from both link_rules[].for and scoring.exempt[].when. A clause carrying another condition keeps it (for: { role: hub, tier: A } becomes for: { role: hub }), and a rule left with nothing to test is dropped rather than silently widened to every page.
Related
- Navigation & relationships — how an SSG reads this graph (link columns, hub members, facets)
- Redirects — the old→current slug ledger + how the SSG emits 301s
- The page registry — where the links actually live
- The mental models — the authority layer and tiering
- Lifecycle & reconcile — why a moved edge is a cheap
relink