Appearance
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.tsThe 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-missingerror. (With progressive mode on, one your change never went near is listed as a warning untilyg 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 matchEntries 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
whenpredicate 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'swhen, oryg checkreports atype-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 —
parentslimits where a node of this type may nest (with noparents, anywhere; with a list, only under those types, and at the top level only when the list names the reserved entryroot);relationslimits which types it may depend on, and through which relation type. - opt into the log gate —
log_required: truemakes a change to a node of this type record a short note on why before it is verified — your agent writes it withyg log add. The requirement is measured from the last fullyg check --approve; a project that only ever runs--only-deterministicnever advances that baseline, so one entry keeps covering every later edit (plainyg checkwarns withlog-cycle-open). See the log gate. - close the type-shopping gap —
enforce: strictmakes 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.)
| Code | What it means |
|---|---|
enforce-strict-without-when | The type declares enforce: strict with no when — there is no predicate to enforce backward against. |
type-strict-orphan | A file matches the predicate but belongs to no node at all. |
type-strict-misplaced | A file matches the predicate but belongs to a node of a different type. |
strict-overlap-conflict | A 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Display name. |
type | string | yes | A node type yg-architecture.yaml defines. |
description | string | yes (description-missing without it) | What the component does; shown in context output. |
aspects | list of string or mapping | no | Rules attached to this node and every node under it (channel 1). |
aspects[].id | string | yes | The rule id — its directory path under aspects/. |
aspects[].when | node predicate | no | Attach the rule here only where this node predicate holds (yg knowledge read conditional-aspects). |
aspects[].status | draft | advisory | enforced | no | Raise the rule's status at this site; lowering it is refused. |
relations | list of mapping | no | This node's dependencies on other nodes. A code dependency on a mapped node that is not declared here is refused (relation-undeclared-dependency). |
relations[].target | string | yes | The node depended on, as its path under model/. |
relations[].type | calls | uses | extends | implements | emits | listens | yes | calls, uses, extends and implements are structural and must form no cycle; emits and listens are events and must be paired. |
relations[].portNames | list of string | no | The 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[].consumes | list of string | no | Deprecated alias of portNames; declaring both is refused. |
relations[].event_name | string | no | A label for the event channel of an emits or listens relation. Documentation only: pairing compares node paths. |
mapping | list of string | no | Files, directories and globs this node owns, relative to the repository root. An empty list is refused. |
ports | mapping of <port> to mapping | no | Named entry points, each with the rules a relation entering through it must satisfy. |
ports.<port>.description | string | yes, except on a port named default | What the port provides. |
ports.<port>.aspects | list of string or mapping | no | Rules a node relating through this port must satisfy (channel 6). |
ports.<port>.aspects[].id | string | yes | The rule id — its directory path under aspects/. |
ports.<port>.aspects[].when | node predicate | no | Attach the rule here only where this node predicate holds (yg knowledge read conditional-aspects). |
ports.<port>.aspects[].status | draft | advisory | enforced | no | Raise the rule's status at this site; lowering it is refused. |
max_direct_relations | mapping | no | A 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.limit | integer ≥ 1 | yes | This 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.reason | string | yes | Why 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.
| Key | Type | Required | Meaning |
|---|---|---|---|
node_types | mapping of <type> to mapping | no | The node types of this graph, by name (* is reserved). |
node_types.<type>.description | string | yes | What a node of this type is. |
node_types.<type>.aspects | list of string or mapping | no | Rules every node of this type carries (channel 3). |
node_types.<type>.aspects[].id | string | yes | The rule id — its directory path under aspects/. |
node_types.<type>.aspects[].when | node predicate | no | Attach the rule here only where this node predicate holds (yg knowledge read conditional-aspects). |
node_types.<type>.aspects[].status | draft | advisory | enforced | no | Raise the rule's status at this site; lowering it is refused. |
node_types.<type>.parents | list of string | no | Node types a node of this type may sit under; root allows the top level. Absent: anywhere. |
node_types.<type>.relations | mapping | no | Which node types each relation type from this type may target. |
node_types.<type>.relations.calls | list of string | no | Node types a calls relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.uses | list of string | no | Node types a uses relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.extends | list of string | no | Node types a extends relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.implements | list of string | no | Node types a implements relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.emits | list of string | no | Node types a emits relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.listens | list of string | no | Node types a listens relation may target (* for any); an empty list allows none. |
node_types.<type>.relations.default | allow | deny | no | What a relation type not listed here may target. Default: allow. |
node_types.<type>.log_required | boolean | no | Whether 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>.when | file predicate | no | The files this type classifies (path and content atoms). |
node_types.<type>.enforce | strict | no | strict: 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.