Skip to content

A node is how you point a rule at the right code. It groups a set of source files into one component — a module, a service, a library — and gives that component a name in the graph. Rules attach to nodes, so the first step in enforcing anything is drawing the line around what counts as one component: that line is what lets you, your agent, and the reviewer all talk about the same piece of code.

You write nodes as small YAML files under .yggdrasil/model/. One file per component.

A node file ​

Here is a complete node — a component called OrderService:

yaml
# .yggdrasil/model/orders/order-service/yg-node.yaml
name: OrderService
type: service
description: "Manages order lifecycle: creation, validation, state transitions"

aspects:
  - requires-audit
  - rate-limiting

relations:
  - target: payments/payment-service
    type: calls

mapping:
  - src/orders/
  - src/orders.ts

The fields:

  • name — display name, shown in CLI output.
  • type — must match a type defined in the architecture file (see Node types). The type decides what rules apply by default and what this component is allowed to connect to.
  • description — one line on what the component does. It shows in CLI context output and helps your agent understand the component. It is not optional: a node, rule, or flow with no description is a blocking description-missing error. (With progressive mode on, one your change never went near is listed as a warning until yg check --full; with no reference branch named — the default — it blocks on every run.)
  • aspects — the rules this component must satisfy. Each name points to an aspect under .yggdrasil/aspects/. See Aspects.
  • relations — the other components this one depends on. See Relations, flows, ports.
  • mapping — which source files this node owns.
  • ports — optional. Named entry points, keyed by port name, that another component's relation can target explicitly; a port's aspects become the caller's obligation once a relation enters through it. See Relations, flows, ports.
  • max_direct_relations — optional. A per-node override of the global high-fan-out ceiling, written as { limit, reason }. It exists for a deliberate single-responsibility seam — one auditable gateway or orchestrator that concentrates coupling by design — and it sanctions a specific reviewed count rather than silencing the warning: the node still warns once it exceeds its own declared limit. See Configuration.

The example above shows the fields a typical node carries, not every field a node file may carry — the last two are omitted far more often than they are used.

Mapping files ​

mapping is the link between the graph and your real code. Each entry is a directory or a file, relative to the repo root:

yaml
mapping:
  - src/orders/         # directory — owns every file inside, recursively (minus files a child node claims)
  - src/orders.ts       # file — exact match

Entries also accept glob patterns: * matches within a single path segment, ** matches across segments. So you can own a slice of a directory without listing files one by one. An entry is a glob only when it contains a * — then full minimatch applies, braces and brackets included. Without a *, the characters ?, [ ] and { } are literal, so a route file such as app/[id]/page.tsx maps as the exact path it is. (A type's when.path is different: it is always full minimatch.)

yaml
mapping:
  - src/db/*Repository.ts   # only *Repository.ts directly in src/db/

src/db/*Repository.ts matches OrderRepository.ts but not Helper.ts and not anything in a subdirectory. src/**/*.ts matches every .ts file anywhere under src/.

Each source file has exactly one owner node, or none at all — a file no node maps can still be enforced automatically by matching exactly one classifying type (see Coverage and minimal nodes below). Either way verification stays unambiguous — there is always at most one component and one set of rules responsible for any given file, never two disagreeing about it. When a parent maps a directory and a child node maps a specific file (or a narrower directory or glob) inside it, the child wins: that file is carved out of the parent's set, so the two never conflict. Child precedence needs a narrower entry to carve out: two nodes — a parent and its child included — carrying the identical mapping entry is a file-duplicate-mapping error. Two unrelated nodes mapping the same file any other way is an overlapping-mapping error.

A file that is both tracked and gitignored is invisible everywhere, so it's flagged

A directory or glob mapping entry expands over a plain directory walk that skips anything .gitignore excludes — it never consults git's index. So a file that is both tracked by git (for example force-added with git add -f) and matched by a .gitignore pattern is invisible to coverage and to mapping alike, no matter what directory or glob mapping it falls under: it ships in your repository, yet nothing that governs coverage or enforcement ever sees it. yg check catches this as tracked-file-gitignored, mirroring your coverage levels exactly: an error under a coverage.required root, a warning elsewhere, and no issue at all under a coverage.excluded root — the same exclusion authority the coverage scan itself honors, so an excluded area stays silent here too.

One exemption on top of those coverage levels: a file named directly in a mapping entry (not swept in via a directory or glob) is hashed and reviewed no matter what .gitignore says, so it was never actually invisible — that's the mirror case below, and tracked-file-gitignored leaves it alone rather than raising a second error with a contradictory fix.

The mirror case blocks too: a mapping entry that names a gitignored file directly — tracked or not — is file-mapping-gitignored: either the file belongs in the repository, or it does not belong in the mapping. The check keys on .gitignore, not on git's index, so a file that is merely untracked (not yet committed, not ignored) is fine to map. If the real reason the file never reaches this check as tracked-and-ordinary is that it is excluded from the graph — it sits inside a separate project's own boundary (a nested .yggdrasil/ graph, or its own .git — a checkout, submodule, or worktree), or it matches a coverage.excluded root you configured — the error is file-mapping-excluded instead, naming that cause directly rather than blaming a .gitignore rule that may not even exist. Exclusion is absolute: it cuts that file even though the mapping names it directly, exactly like it cuts a directory or glob entry that only sweeps the file in.

Nesting and inheritance ​

Nodes nest by directory. A node at model/orders/handler/ is a child of model/orders/. Children inherit their parent's aspects: a rule attached to orders applies to orders/handler and every other node beneath it. Add a rule once at the top of a subtree and it covers the whole subtree.

Every directory between model/ and a node must itself be a node: the graph is read through nodes, so a yg-node.yaml under a directory that has none — model/orders/handler/ with no model/orders/yg-node.yaml — is never loaded. The same holds beneath a node whose own yg-node.yaml does not load. yg check reports each such node as node-unreachable, naming the directory that cut it off; the fix is a yg-node.yaml in that directory (an organizational type with no when is enough) or moving the node up.

Coverage and minimal nodes ​

You do not have to enforce rules on a component to put it in the graph. When you adopt Yggdrasil on an existing codebase, most of your code is not under enforcement yet — and that is fine. Create a node with a mapping and no aspects:

yaml
name: LegacyAuth
type: module
description: "Legacy auth — mapped for coverage, no rules yet"
mapping:
  - src/legacy/auth/

A node on which no rule is effective — none of its own, and none from its type, an ancestor, a flow or a port it enters — produces no rule verdicts. It satisfies the coverage requirement for free. (The lock still records its source fingerprint in yg-lock.logs.json, as it does for every node with mapped source, and a log baseline if it owns a log.md; see The lock.) (One built-in check still runs on any node that maps code: if its files import another node's code, that dependency has to be declared as a relation — see Relations, flows, ports.) The point is to get all your code mapped cheaply, then add rules where they matter, one component at a time. When you are ready to enforce something here, add an aspect to the node.

The node's type still has to be one that classifies files — a type with a when predicate. A purely organizational type (no when) cannot map files at all; yg check rejects a mapping on such a type.

An alternative for files that do not need a node yet ​

A minimal node still costs something: a YAML file per component, upkeep whenever a file moves, and silence about a new file until someone writes it a mapping. If coverage.type_level is on, a file matched by exactly one classifying type's when is enforced by that type's per-file rules automatically — no YAML at all, and a brand-new file is covered the moment it matches, not whenever a node catches up with it.

The trade runs the other way too, so this is not a strict upgrade over a minimal node: a type-covered file has no component to attach a per: node rule to (only scope: { per: file } rules can ever reach it), no log, no flows, and no place to hang a curated relation or a note about why the code looks the way it does. A minimal node stays the right call once a file earns any of those — cross-file review, business intent worth logging, participation in a flow. Reach for type-level coverage first for the code that is genuinely uniform and growing (another handler, another repository); reach for a node, minimal or not, the moment one file needs something the type alone cannot say.

Coverage — which files must be mapped, and how strictly — is configured separately. See Configuration.

Node types (the architecture file) ​

Every node declares a type, and every type is defined once in .yggdrasil/yg-architecture.yaml. Types are the vocabulary of your architecture. A type can:

  • classify files — a when predicate says which source files belong to this type, so your agent can place new files correctly. It is also enforced forward: every file a node of this type maps must match the type's when, or yg check reports a type-when-mismatch.
  • set default rules — list aspects every node of the type must satisfy, so you attach a cross-cutting rule once instead of on every node.
  • constrain structure — parents limits where a node of this type may nest (with no parents, anywhere; with a list, only under those types, and at the top level only when the list names the reserved entry root); relations limits which types it may depend on, and through which relation type.
  • opt into the log gate — log_required: true makes a change to a node of this type record a short note on why before it is verified — your agent writes it with yg log add. The requirement is measured from the last full yg check --approve; a project that only ever runs --only-deterministic never advances that baseline, so one entry keeps covering every later edit (plain yg check warns with log-cycle-open). See the log gate.
  • close the type-shopping gap — enforce: strict makes the classification bite in both directions. See below.

A compact example:

yaml
node_types:
  module:
    description: "Business logic unit with a clear domain responsibility"
    when:
      path: "src/**"                  # a module owns source files, so its type needs a when

  service:
    description: "Provides functionality to other components"
    aspects: [requires-audit]       # every service must satisfy this rule
    log_required: true              # service changes carry intent worth recording
    parents: [module]               # a service nests under a module
    relations:
      calls: [service, library]     # a service may call services and libraries
      uses: [library]
    when:
      path: "src/**/*.service.ts"   # files that make a node a "service"

  library:
    description: "Shared utility code with no domain knowledge"
    when:
      path: "src/shared/**"

A type with a when predicate classifies files. A type without when is organizational — usable as a parent in the hierarchy, but its nodes cannot map any files. The boolean combinators (all_of / any_of / not) are the same ones used for conditional aspects, but the atoms differ by site: a type's when classifies files and accepts only path: (a minimatch glob on the repo-relative path) and content: (a JavaScript regular expression tested against the first 256 KiB of the file; a binary file never matches, and a file over 5 MB cannot be read for it at all, so a mapped file that size under a type with a content: atom is a blocking file-unreadable), whereas an aspect's when filters nodes and uses node atoms instead. Here, use path and content.

enforce: strict — both directions ​

By default a type's when is checked forward only: every file a node of that type maps must match the predicate. Nothing stops the reverse — a file that matches service's predicate can quietly live in a library node, or in no node at all, and never pick up the rules the service type carries. That is the type-shopping gap.

enforce: strict closes it by also checking backward: every file in the repository matching the type's when must belong to exactly one node of that type.

yaml
node_types:
  service:
    description: "Provides functionality to other components"
    aspects: [requires-audit]
    enforce: strict                  # both directions
    when:
      path: "src/**/*.service.ts"

Reach for it on the types where missing the type means missing a rule that matters — security, audit, anything regulatory. Do not reach for it while a predicate is still broad: enforce: strict on path: "**" demands that every file in the repository sit in that one type's mappings. Run yg impact --type <id> before you flip the flag; it previews which files would come out as orphans or misplaced, so you fix the gaps first rather than turning the build red to find them.

Do not reach for it on a directory shared by components of more than one type. A strict type's predicate demands that every matching file belong to exactly one node of that type — so a directory where files belonging to two different node types genuinely live side by side produces type-strict-misplaced on everything that doesn't happen to belong to the strict type, and that is not a tool bug, it is the predicate doing exactly what enforce: strict asked for on a directory it cannot own alone. Narrow the predicate (a tighter path: glob, or a content: check) or physically split the directory so each type's files have their own home — never drop strict from the type that actually needs it just to silence the noise.

The backward scan honors coverage.excluded like every other coverage question does: a file under an excluded root is never a candidate for type-strict-orphan or type-strict-misplaced, even when it satisfies the type's when. A path you have excluded is gone from this graph's coverage entirely — not merely from the ordinary coverage levels — so the backward scan has nothing to say about it either.

Four errors are specific to strict types, and each blocks yg check. (With progressive mode on, the three that are about a particular file or component are listed as warnings when your change reached neither, and yg check --full blocks on them again; enforce-strict-without-when is about the type declaration itself and blocks either way. With no reference branch named — the default — all four block on every run.)

CodeWhat it means
enforce-strict-without-whenThe type declares enforce: strict with no when — there is no predicate to enforce backward against.
type-strict-orphanA file matches the predicate but belongs to no node at all.
type-strict-misplacedA file matches the predicate but belongs to a node of a different type.
strict-overlap-conflictA file matches the when of two strict types, so neither can own it unambiguously. Narrow one predicate.

They are reported alongside any unmapped-files coverage error, not folded into it — the symptoms are distinct and so are the fixes. That changes only under coverage.type_level: once it is on, a file this table already accounts for (or one ambiguous-node-type accounts for) is dropped from the plain unmapped-files/uncovered-advisory listing — one issue per file, the most-binding one.

A file that matches an enforce: strict type is never also reported as ambiguous by coverage.type_level — the strict error above owns it, and (with type_level on) that error's message lists any other type the file also matches. type_level's own ambiguity error, ambiguous-node-type, exists for the same shape of problem among ordinary (non-strict) types.

The architecture file is the foundation of the graph, so changes to it ripple across every node of the affected type. Change it deliberately, and confirm the change before applying it.

An error in the type system itself stops validation before any node is looked at. When the file fails to load (architecture-invalid), or it declares a parents cycle (architecture-cycle), a parent type that does not exist (type-unknown-parent), a reserved type name (type-name-reserved), a malformed when (when-predicate-invalid) or enforce: strict without a when (enforce-strict-without-when), yg check reports only those errors and the handful of checks that do not depend on the types: every per-node finding — mappings, relations, flows, ports, type-when mismatches, the strict scan — stays hidden until the type system is fixed. A clean run after the fix can therefore bring up errors the previous run never showed.

A note on prompt size ​

Keep nodes a sensible size, because a component's files all reach the reviewer in one prompt. For a reviewer rule, all of the component's subject files go to the reviewer together with the rule, reference files, and — when the aspect ships a companion.mjs hook — any companion files resolved for that unit. All of these count toward the max_prompt_chars ceiling a reviewer tier can set. If an assembled prompt would exceed it, yg check reports prompt-too-large instead of letting the oversized pair through. The usual fix is to split an oversized node into smaller ones, or narrow which files a rule reviews. Script rules read files directly and have no prompt, so this never applies to them. See Configuration.

Field reference ​

yg-node.yaml ​

Generated from the schema the parser enforces — the same table yg schemas read node prints.

KeyTypeRequiredMeaning
namestringyesDisplay name.
typestringyesA node type yg-architecture.yaml defines.
descriptionstringyes (description-missing without it)What the component does; shown in context output.
aspectslist of string or mappingnoRules attached to this node and every node under it (channel 1).
aspects[].idstringyesThe rule id — its directory path under aspects/.
aspects[].whennode predicatenoAttach the rule here only where this node predicate holds (yg knowledge read conditional-aspects).
aspects[].statusdraft | advisory | enforcednoRaise the rule's status at this site; lowering it is refused.
relationslist of mappingnoThis node's dependencies on other nodes. A code dependency on a mapped node that is not declared here is refused (relation-undeclared-dependency).
relations[].targetstringyesThe node depended on, as its path under model/.
relations[].typecalls | uses | extends | implements | emits | listensyescalls, uses, extends and implements are structural and must form no cycle; emits and listens are events and must be paired.
relations[].portNameslist of stringnoThe target's ports this relation enters through; each port's rules then apply to this node. Absent means the implicit default port; an empty list is refused.
relations[].consumeslist of stringnoDeprecated alias of portNames; declaring both is refused.
relations[].event_namestringnoA label for the event channel of an emits or listens relation. Documentation only: pairing compares node paths.
mappinglist of stringnoFiles, directories and globs this node owns, relative to the repository root. An empty list is refused.
portsmapping of <port> to mappingnoNamed entry points, each with the rules a relation entering through it must satisfy.
ports.<port>.descriptionstringyes, except on a port named defaultWhat the port provides.
ports.<port>.aspectslist of string or mappingnoRules a node relating through this port must satisfy (channel 6).
ports.<port>.aspects[].idstringyesThe rule id — its directory path under aspects/.
ports.<port>.aspects[].whennode predicatenoAttach the rule here only where this node predicate holds (yg knowledge read conditional-aspects).
ports.<port>.aspects[].statusdraft | advisory | enforcednoRaise the rule's status at this site; lowering it is refused.
max_direct_relationsmappingnoA reviewed override of the high-fan-out ceiling for this node only. A malformed value is ignored: an override missing either field, or with a limit below 1, leaves the global ceiling in force.
max_direct_relations.limitinteger ≥ 1yesThis node's own relation ceiling, above or below the global quality.max_direct_relations. A malformed value is ignored: an override missing either field, or with a limit below 1, leaves the global ceiling in force.
max_direct_relations.reasonstringyesWhy this node gets its own ceiling. A malformed value is ignored: an override missing either field, or with a limit below 1, leaves the global ceiling in force.

Retired keys, refused by name with what became of each (yg init --upgrade removes them): sizeExempt (removed in 5.0.0 with the per-node character budget; the per-tier max_prompt_chars cap replaced it); relations[].failure (removed in 4.0.0); ports.<port>.version (removed in 6.0.0: Yggdrasil no longer records contract versions); ports.<port>.test (removed in 6.0.0: Yggdrasil no longer runs contract tests). Any other key is refused by name, with the key it is probably a typo of.

yg-architecture.yaml ​

Generated from the schema the parser enforces — the same table yg schemas read architecture prints.

KeyTypeRequiredMeaning
node_typesmapping of <type> to mappingnoThe node types of this graph, by name (* is reserved).
node_types.<type>.descriptionstringyesWhat a node of this type is.
node_types.<type>.aspectslist of string or mappingnoRules every node of this type carries (channel 3).
node_types.<type>.aspects[].idstringyesThe rule id — its directory path under aspects/.
node_types.<type>.aspects[].whennode predicatenoAttach the rule here only where this node predicate holds (yg knowledge read conditional-aspects).
node_types.<type>.aspects[].statusdraft | advisory | enforcednoRaise the rule's status at this site; lowering it is refused.
node_types.<type>.parentslist of stringnoNode types a node of this type may sit under; root allows the top level. Absent: anywhere.
node_types.<type>.relationsmappingnoWhich node types each relation type from this type may target.
node_types.<type>.relations.callslist of stringnoNode types a calls relation may target (* for any); an empty list allows none.
node_types.<type>.relations.useslist of stringnoNode types a uses relation may target (* for any); an empty list allows none.
node_types.<type>.relations.extendslist of stringnoNode types a extends relation may target (* for any); an empty list allows none.
node_types.<type>.relations.implementslist of stringnoNode types a implements relation may target (* for any); an empty list allows none.
node_types.<type>.relations.emitslist of stringnoNode types a emits relation may target (* for any); an empty list allows none.
node_types.<type>.relations.listenslist of stringnoNode types a listens relation may target (* for any); an empty list allows none.
node_types.<type>.relations.defaultallow | denynoWhat a relation type not listed here may target. Default: allow.
node_types.<type>.log_requiredbooleannoWhether a node of this type needs a log entry when its files change since the last full yg check --approve that closed its cycle (--only-deterministic never closes one; log-cycle-open warns while none has).
node_types.<type>.whenfile predicatenoThe files this type classifies (path and content atoms).
node_types.<type>.enforcestrictnostrict: a file matching when must be mapped to a node of this type, and a node of this type may map only matching files.

Retired keys, refused by name with what became of each (yg init --upgrade removes them): node_types.<type>.sizeExempt (removed in 5.0.0 with the per-node character budget; the per-tier max_prompt_chars cap replaced it). Any other key is refused by name, with the key it is probably a typo of.