Skip to content

Your components depend on each other. A handler calls a service; a service uses a logger. Sometimes a rule has to follow that dependency across a component boundary — the called code must obey a constraint the caller relies on. And sometimes a rule belongs to a whole business process, not a single component.

This page covers the three tools for those cases: relations (typed dependencies), ports (carry a rule across a boundary), and flows (a rule that spans a process). For the components themselves see Nodes; for the rules see Aspects.


Relations ​

A relation keeps your dependencies inside the shape you designed. You declare what each component is allowed to depend on, and the graph holds every component to it. That declaration is a relation: a dependency from one node to another, written in the depending node's yg-node.yaml:

yaml
# orders/order-service/yg-node.yaml
relations:
  - target: payments/payment-service
    type: calls
  - target: shared/logger
    type: uses

There are six relation types, in two groups:

  • Structural — calls, uses, extends, implements
  • Event-based — emits, listens

The architecture file constrains which types may target which. Each node type either leaves a relation type unconstrained (the default — it may target any type) or lists the target node types it may reach; yg check rejects a relation whose target is not in a declared list (relation-target-forbidden), and a list that names a type the architecture does not define (relation-target-type-unknown). You can also lock a type down: default: deny forbids every relation type the node does not explicitly list (a sink), an empty list (uses: []) forbids a single relation type, and the wildcard (uses: ['*']) opens one to any target. An omitted default means allow, so this is fully backward-compatible. So if you decide a service may only call other services and use libraries, the graph holds every service to that.

Event relations come in pairs. If A emits to B, then B must declare a listens from A. yg check enforces the pairing with a blocking event-unpaired error. The pairing is matched by node path only — the optional event_name on the relation is documentation and is never compared.

Relations earn their keep two ways: yg impact uses them to compute the blast radius of a change, and the architecture allow-list keeps dependencies inside the shape you designed.


Declared relations must match real dependencies ​

The graph's relations only help if they match reality. Yggdrasil keeps them honest with one built-in check.

On every yg check, it parses your actual source — TypeScript/JavaScript/TSX, Python, Go, Java, PHP, Kotlin, Rust, C, C++, C#, and Ruby — and finds where one component depends on another component's code. If that dependency is not declared as a relation, it refuses the component. The issue code is relation-undeclared-dependency.

What counts as a dependency depends on the language, and in most of them the build layout decides where an import lands. TypeScript and JavaScript, and Java and Kotlin, have sections of their own below; the rest:

  • C and C++ depend on each other only through #include. A quoted include is looked up next to the including file first. After that, the check uses the include roots from a compile_commands.json at the repository root or in build/: the -iquote and -I directories of each translation unit, never -isystem. An angle include (#include <lib/x.h>) is looked up only under those -I roots. Without a compilation database, a quoted include of at least two path segments, such as "net/socket.h", is also looked up at the repository root and under every directory named include. A bare "config.h" is never looked up that way, and neither is an angle include. A header found under two roots gives no edge. Includes in a preprocessor branch that is constant-false (#if 0, #if (0), #if false, the #else of #if 1) are ignored, but every branch that depends on a macro counts. Expect missed dependencies without a compilation database: -I roots other than include/ directories and the repository root, headers generated into the build tree, macro includes, and header-unit import "x.h"; are not seen. Generate the database (CMake: -DCMAKE_EXPORT_COMPILE_COMMANDS=ON; Bazel, Meson and bear can write one too) to close most of that gap. A .h header is read as C++ when its directory holds C++ files and no .c file.
  • PHP class references resolve through Composer autoload maps: PSR-4 (including the "" fallback prefix) and PSR-0 from the nearest composer.json, then from every composer.json in the repository (vendor/ excluded), so a monorepo's cross-package use is seen. A class found in two places gives no edge. Besides use imports, class names in class positions (new, types, extends, ::, attributes, instanceof) are resolved as PHP resolves them, from the file's namespace and imports. require/include counts only when the path is built from __DIR__ or dirname(__FILE__) plus a literal; a path resolved through include_path is not seen. classmap and files autoloading are not read.
  • Python imports resolve under the importing file's own source roots and, besides those, under every root a project file declares: each directory with a pyproject.toml, setup.cfg or setup.py contributes its src/ child (or itself, in the flat layout), which covers tests beside a src layout and uv or Poetry workspace members. Such a root never matches a standard-library name, and a directory holding an __init__.py (or whose parent does) is never a root, so import logging never binds to an in-repo logging.py of an ancestor package. A module found under two roots gives no edge.
  • Go imports bind through the longest in-repo module path among the importing file's own go.mod module, every ancestor module and every use member of the nearest go.work. A package directory that a deeper go.mod claims for a different module path is silent, and replace directives are not modelled.
  • Rust use paths resolve through the crate's module tree: every item of a use tree on its own, crate:: from the crate root (src/lib.rs, else src/main.rs; src/bin/*, tests/, examples/ and benches/ are crate roots of their own), super and self relative to the enclosing (inline) module. In-repo Cargo path dependencies ({ path = "../core-lib" }, { workspace = true }, package = renames, [lib] name) resolve use core_lib::… and extern crate core_lib. Registry crates are external, and a #[macro_export] macro defined elsewhere stays silent.
  • C# references resolve by namespace, with each project's global usings (a global using, or <Using Include> in a .csproj or Directory.Build.props) applied only to the files of that project, the nearest .csproj. Besides using directives, a type named in code counts: a generic argument, a qualified name, a member accessed through a type name (OrderStatus.Paid), a call to an in-repo extension method in a namespace the file encloses or imports. A reading that could be wrong stays silent: a receiver name the file also declares as a local, parameter or member, a declaration split across two components, an extension name declared in two components.
  • Ruby constants resolve as Ruby looks them up, through Module.nesting: a bare constant inside a class or method body binds when exactly one in-repo definition answers, an unrooted A::B inside a namespace is tried nearest first, only a ::-rooted reference is absolute, and a compact class Billing::Invoice in …/billing/invoice.rb anchors the namespace Zeitwerk creates from the directory. A reference rooted at a Ruby core class or a Rails, Rack, RSpec, Minitest, Sinatra, Rake, Bundler or Gem namespace never binds to an in-repo reopening. .rake, .gemspec, .ru, Rakefile, Gemfile and the like are read as Ruby.

The benefit is a map you can trust. Blast-radius analysis and the architecture allow-list mean nothing if the code quietly depends on things the graph never mentions. This check closes that gap.

Three properties keep false alarms out. The check is designed to stay silent rather than guess, not guaranteed never to be wrong: a resolution rule that misreads a layout gives a false edge until it is fixed, and several such rules were fixed in 6.1.0.

  • One-directional. A real code dependency must be declared. The reverse is not required: a declared relation needs no code behind it. Dependencies over HTTP, dependency injection, reflection, and events are legitimately declared without any resolvable call in the source, and the check never complains about a relation with no matching code.
  • Mapped-target-only and unambiguous-only. It fires only when the depended-on file is mapped to a known node — a dependency on an unmapped file is a coverage matter, not a relation error. And it resolves only dependencies it can pin to exactly one target. Anything dynamic, reflective, external, or not uniquely resolvable is left alone.
  • Hierarchy is exempt. A dependency inside one component, or between a component and its own ancestor or descendant, needs no relation — those are not edges between two distinct components, so the check skips them.

This is not an aspect. It has no rule file, it is not attached to your nodes, and the draft/advisory/enforced levels do not apply — it is always an error, and it cannot be suppressed. On a project that names no reference branch — the default — that error blocks yg check unconditionally, exactly like the architecture and mapping validators.

One project-level setting changes where it blocks, and only there. When progressive mode is on, this refusal is one of the findings a change can inherit: a refusal your change did not reach is listed as a warning instead of an error, and yg check --full blocks on it again. Nothing about the check itself moves — it is still not an aspect, still has no status, still cannot be suppressed, and it still blocks the moment your change reaches the code that carries it.

There are two ways to clear a refusal:

  1. Declare the relation in the component's yg-node.yaml, with a structural type (calls, uses, extends or implements) the architecture allows between the two node types. An event relation (emits / listens) does not count: it describes a message the two exchange, not an import of the other's code. A relation declared to a parent node also sanctions dependencies on any of its descendants, so you can point one relation at a subtree's root instead of at each child.
  2. Remove the dependency if the code should not depend on the other component.

If no structural relation type is allowed between the two node types, that is an architecture decision. Your agent surfaces it for your confirmation — you either change a node's type so an allowed relation exists, or extend the allowed relations in yg-architecture.yaml.

One caveat on declaring the relation: the four structural relation types (calls, uses, extends, implements) must form a DAG. If two components depend on each other, declaring both directions creates a cycle, which a separate always-blocking validator rejects with a structural-cycle error (a component relating to itself counts too). Break the cycle — extract the shared piece into a third component both depend on — rather than declaring a mutual dependency. An emits / listens pair is no way around it: event relations sanction no import.

It also never passes over code it could not read. If a language's parser cannot be loaded, every file in that language would contribute zero detected dependencies — which would look exactly like "this file depends on nothing". Rather than go green over unanalyzed code, the check fails closed with a blocking relation-parse-failed naming the language and an affected file; reinstalling the CLI restores a missing grammar. The same code, with a message of its own, covers a grammar that loads but fails on one particular file — a parser trap that survives a retry on a fresh parser. Reinstalling does not help there: check the file is valid source, and report it as a parser failure if it is.

One detail worth knowing: this check runs on every yg check, not only yg check --approve. Its result is never cached: the resolve-and-verify join runs live on every call, so it is always the current truth of your code against the graph, at zero reviewer cost. (Parsing a file is served from a content-addressed cache when its bytes are unchanged, but the resolution and the verdict are always recomputed.) That is what lets a keyless CI yg check catch an undeclared dependency even though it makes no reviewer calls. When adopting Yggdrasil on an existing codebase, the first run names every file, target, and the exact relations: stanza to add.

Java and Kotlin ​

Java imports resolve by the package = directory convention under the importing file's own source root first. When that finds nothing — the class lives in a sibling Maven or Gradle module, or a test under src/test imports production code under src/main — the import is looked up by its fully-qualified name across every mapped Java and Kotlin file, and it counts only when exactly one file declares that name. Java and Kotlin share that namespace: a Kotlin file importing a Java class and a Java file importing a Kotlin class, object or file facade (OrderUtilsKt, or the name given by @file:JvmName) are both detected. A star import (import a.b.* in either language) is a dependency only when every mapped file of that package belongs to one component; a package split across components stays silent.

The Kotlin parser Yggdrasil ships (@tree-sitter-grammars/tree-sitter-kotlin 1.1.0, a fork last released in January 2025) predates Kotlin 2.2. It does not know when-guards (is String if s.isNotEmpty() ->), multi-dollar strings ($$"…") or context parameters (context(log: Logger)), and on each of them it gives up on the rest of the file. The check recovers: it re-reads such a file with those constructs masked, then declaration by declaration, so the declarations after them still count. Two limits remain. A type written inside a context(…) clause is not detected. And where a declaration still cannot be read, an import into that file's package that could have meant it stays silent rather than risk pointing at the wrong component. The actively developed Kotlin grammar (fwcd/tree-sitter-kotlin) handles when-guards and multi-dollar strings but has no npm release and uses different node names, so moving to it is a separate change.

What counts as a TypeScript or JavaScript dependency ​

A TypeScript/JavaScript dependency is a construct that names a module by a string literal: a static import, a re-export (export … from), import x = require(…), export = require(…), require('…'), an import('…') in a value or a type position, a module augmentation declare module '…', and new URL('…', import.meta.url). Calls, JSX, extends and type references only use what an import already brought in, so they add nothing. A mock registration such as vi.mock('…') is a call argument, not an import.

Type-only references are dependencies. A relation records that one component's code depends on another's, not only that it loads it at runtime. A file that compiles only against another component's types breaks when those types change, so every spelling of a type-only reference gives an edge, exactly like a value import, and a missing relation is a blocking relation-undeclared-dependency: import type { T } from, import type * as T from, an import whose every named specifier carries an inline type, export type { … } from, export type * from, import type X = require(…), and an import('…') in a type position (typeof import('./m'), let v: import('./m').T, the type side of as or satisfies, a type argument). If the architecture allows no relation between the two components, move the shared types into a component both may depend on.

A module augmentation declare module './m' { … } merges declarations into ./m and compiles only against it, so it gives an edge too. A relative name is always an augmentation. A bare name (declare module '@acme/b') is an augmentation in a module file, one with a top-level import or export, and gives an edge when the name resolves to an in-repo package. In a script file the same header is an ambient declaration: it declares a module's shape instead of depending on one, and stays silent. So does a wildcard pattern such as declare module '*.css'.

Two type references are not detected, because they live in comments and the check reads syntax, not comment text: a triple-slash directive (/// <reference path="…" />) and a JSDoc import type (/** @type {import('./m').T} */). Before 6.1.0, import type, export type and all-inline type clauses were silent while the type-position import() forms gave edges.

A specifier resolves to a file in this order. A bundler query suffix (?raw, ?worker) is stripped first.

  1. Relative (./x, ../x): joined onto the importing file's directory.
  2. Root-absolute (/src/x, the Vite and Next.js convention): joined onto the importing file's package root, the nearest ancestor directory with a package.json. With no package root it stays silent.
  3. Package imports (#internal/x): through the imports map of the nearest package.json.
  4. Bare (@/x, src/x, @acme/b, @acme/b/sub): first the paths of the nearest tsconfig.json (a JavaScript file also reads jsconfig.json), with its extends chain applied: the exact key, else the longest wildcard prefix. When no paths pattern matches, its baseUrl; a pattern that matches but resolves to no file goes straight to the in-repo package lookup, as the compiler does, and never to baseUrl. Otherwise an in-repo package whose package.json name matches, resolved through its exports, or its types/typings/module/main fields and index file. Any other bare specifier is an external package and stays silent, as does any specifier with a scheme (node:path).

A candidate is then probed the way the compiler probes it. A .js specifier finds the .ts source it compiles from, then a declaration file (.ts, .tsx, .d.ts, then .js; .mjs → .mts, .d.mts; .cjs → .cts, .d.cts). Other explicit extensions (.json, .css, .svg, .vue, or a dotted stem such as ./app.config) are probed as the file itself first, then with each source extension appended (./app.config → app.config.ts). An extensionless path gets the source extensions appended, with .d.ts after .ts and .tsx. A directory is read through its own package.json entry, then its index.ts|tsx|d.ts|js|jsx|mjs|cjs.

When the answer is not unique the check stays silent. That happens when a paths pattern has two substitutions that both name an existing file, when two in-repo packages share one name, or when a tsconfig.json is unreadable or extends a relative file that does not exist. A published preset in extends (@tsconfig/node20) that is not installed is taken to set no paths or baseUrl.

The imports inside Vue and Svelte components are checked too. A .vue or .svelte file is read through its <script> blocks, with the grammar their lang names, and at the component's own line numbers.

A grammar can turn valid syntax into a parse error that swallows a statement. With the grammars shipped today, the known cases are a re-export with import attributes (export … from '…' with { … }) and a deferred import (import defer * as m from '…'), both in TypeScript and JavaScript; see Known grammar limits. Lines inside such an error region are rescanned for import and re-export statements, type-only ones included, so a swallowed import still gives its edge. A clean parse is never rescanned.

The same gate, widened to type-covered files ​

Everything above governs edges between two explicit nodes, using each node's own declared relations: list. With coverage.type_level on, a second, additive gate runs alongside it: every statically-resolved import whose endpoints are both classified — an explicit node, a type-covered file, or one of each — is checked against the architecture's relation allow-list for the two node types involved, issue code type-relation-forbidden. It exists because a type-covered file has no yg-node.yaml of its own to declare a relation in, so the ordinary check above has nothing to attach to on that side of the edge. An edge into an ambiguous or unmatched file is never gated — this check can only see edges whose target already resolved to a type.

Like the built-in relation-conformance check, this is not an aspect (no status, no yg-suppress) and it is never cached — it runs live, at zero reviewer cost, on every yg check. It follows that check in the other respect too: it blocks unconditionally by default, and under progressive mode a refusal your change did not reach is listed as a warning, with yg check --full blocking on it again. Clearing a refusal has three exits instead of two, cheapest first: allow the type pair in yg-architecture.yaml (clears every edge between those two types at once), give the target file an explicit node with a curated relation (restores ordinary declared-edge semantics for just that file), or remove the dependency.

Be honest with yourself about how much this gate is actually doing. A node type with no relations: table at all has an absent default, and an absent default means allow — every relation type, to every target — so the gate is vacuous for that type's edges until you write one. A project with no relation tables anywhere gets zero protection from turning type_level on; the gate exists, but nothing is declared for it to enforce against. The free way to see how much a real table would catch: add one deny-default table (relations: { default: deny, uses: [library] }, say), run plain yg check, read what it names, and decide whether to keep it or revert — no --approve, no cost, no commitment. A mature set of deny-default tables converts every silent explicit-to-uncovered-type edge into a blocking error the moment you turn the flag on; an empty or allow-everything architecture converts none of them.

Known grammar limits ​

Each language is parsed by a pinned tree-sitter grammar. The grammar manifest of @chrisdudek/runes (the relation code Yggdrasil and Grain share, which the CLI installs at an exact version) records the version, the upstream commit and the sha256 of every grammar the CLI ships, and the build refuses a grammar whose bytes differ from its pin. A construct a grammar does not know becomes an ERROR node in the tree. A rule that reads the AST (ctx.parseAst, a file's .ast) sees that ERROR. The relation check recovers edges from inside one only through its documented rescans — the import lines of a TypeScript or JavaScript error region, the line-by-line include scan of a C or C++ file, the masked re-read of a Kotlin file — and never guesses one from anything else, so it can miss a declaration or an import that the error swallowed.

These gaps are known in the shipped grammars, checked on 2026-09-24 (the Rust row on 2026-09-27):

LanguageGrammar shippedParses as ERROR or misparsesUpstream
Go0.25.0Go 1.27 generic methods (func (r *T) M[X any]() …), which also drop out of the file's declarations; Go 1.26 new(expr) (new(42)). Imports are unaffected.No fix; no grammar change since 2025-09.
TypeScript / TSX0.23.2 with upstream PRs #357, #358, #364, #365 applied, regenerated against JavaScript 0.25import defer * as m from "…" (also in JavaScript); static accessor x; export … from "…" with { … } (also in JavaScript). using / await using declarations, export type *, in/out variance and typeof import("…").X<T> parse.The PRs are open and unmerged; nothing handles the other three yet.
Rust0.24.2Rust 2024 safe items in an unsafe extern block (unsafe extern "C" { pub safe fn f(); }).Upstream status not checked.
Java0.23.5Java 25 flexible constructor bodies (statements before super(…) / this(…)); import module java.base;. Both edges around them survive.No grammar change since 2024-12.
Kotlin1.1.0 (tree-sitter-grammars fork)Kotlin 2.2+ when guards (is Int if x > 0 ->), context(…) parameters and $$"…" strings turn the rest of the file into one ERROR.The fork has been dormant since 2025-01; the fwcd grammar has the fixes but different node types and no npm release.
C#master 9150f7d (after 0.23.5)C# 14 extension blocks (extension(Widget w) { … }) and a?.B = 1 parse. No known gap found.—
PHPmaster 3fda2fb9 (after 0.24.2)PHP 8.5 clone($obj, [...]). PHP 8.4 asymmetric visibility (private(set)) now parses, in constructor promotion too.clone with arguments: open.
Python0.25.0PEP 696 type-parameter defaults (def f[T = int](), class A[T = str]).Also fails on master.
Rubymaster ad907a69 (after 0.23.1)A Ruby 4.0 leading || / && continuation line after an assignment (x = foo(1) followed by a line that starts with || bar) splits into two statements.Open. Master's heredoc fix (a delimiter of 256+ characters crashed the parser) is included.
C0.24.2#embed inside an initializer; _BitInt(N).Open.
C++master c0092228 (after 0.23.4)Static lambdas ([] static () { … }) and if consteval leave small ERROR nodes. Modules (export module, import "x.hpp") and explicit object parameters (this S& self) parse.Open.
JSON0.24.8An exponent with an explicit plus sign (1e+5).Fixed on master (2026-08-17), not released.
TOML0.7.0TOML 1.1: multi-line inline tables, the \e escape, times without seconds (07:32).No release supports TOML 1.1.

When a grammar changes, every script verdict that read a syntax tree of that language is re-judged on the next yg check --approve (free and keyless for script rules), because each such verdict records which grammar and parser runtime built the trees it read.


Ports ​

A relation connects two nodes. It does not carry the target's rules to the caller. Most of the time that is correct — calling a service does not make the service's internal rules your problem.

But sometimes it should. When the target enforces a rule that consumers must also satisfy — a correlation ID that has to flow through the call, an idempotency key, an audit trail — you model it as a port.

A port is a named entry point on a node with required aspects:

yaml
# payments/payment-service/yg-node.yaml
ports:
  charge:
    description: "Charge a payment method"
    aspects: [correlation-tracking]

A relation opts into the port by naming it in portNames:

yaml
# orders/order-service/yg-node.yaml
relations:
  - target: payments/payment-service
    type: calls
    portNames: [charge]

(consumes: still works, as a deprecated alias for portNames:.)

Now orders/order-service must satisfy correlation-tracking for its own code, because its relation names the charge port. The rule has crossed the boundary.

Why this exists. A port is a named entry to a node. default is the name every node carries without declaring it, and it is the entry every relation uses unless the relation names another. A rule attached to a parent node reaches all of its children automatically, but it does not cross a relation: a helper that lives outside the audited parent, yet gets called from inside it, would slip past the audit rule. Ports restore the boundary — the owner publishes the rule on a port, and the rule reaches every relation that enters through that port. Aspects flow through whichever entry declares them, and only through the entry a relation actually names: a relation that names a specific port enters only through that port, not through default as well. You declare a port to hang a rule on it — never to force anyone to declare anything; a relation that names nothing is the ordinary path, not a gap.

Three blocking errors keep the port contract honest, and none of them has an "accept the gap" option:

CodeWhen it firesFix
port-undefinedA relation names a port the target does not publish — including when the target publishes no ports at all. default never fires this: it always exists.Fix the port name, or add the missing port to the target.
port-missing-aspectA named port — default included — lists a rule that is not defined under aspects/.Define the rule, or remove it from the port. (An undefined id is caught as aspect-undefined whether or not a relation names the port; this code is the "and a relation actually enters through it" case.)

An empty portNames list (or its deprecated alias) is not a code of its own: it fails node parsing, so yg check reports it as yaml-invalid, and the text port-names-empty appears only inside that error's message — filtering yg check --json or writing a suppression on it matches nothing. Omit the field to enter through default, or name at least one real port.

Each message names the relation, explains what would go unverified, and tells you what to add.

Declaring a port literally named default is how you hang aspects on the implicit entry every node already carries; those aspects then apply to every consumer that names no port. yg check raises nothing for the declaration itself.


Flows ​

A flow is a business process that spans several components — "customer places an order, payment is captured, inventory is reserved." It groups the participating nodes and attaches shared rules to all of them.

yaml
# .yggdrasil/flows/checkout/yg-flow.yaml
name: Checkout
description: "Customer places order, payment is processed, inventory reserved"
nodes:
  - orders/order-service
  - payments/payment-service
  - inventory/inventory-service
aspects:
  - correlation-tracking

nodes: may also be written as participants: — the parser accepts the two as full aliases, and its own error messages name both spellings, so a file using either key (or an error quoting the other one) is reading the same field. Write one of them, not both: a flow that declares both is refused, since one list would otherwise be dropped without a word.

Every aspect on the flow applies to every participant. So correlation-tracking above is now a rule each of those three services must satisfy — one place to require it across a whole process, instead of repeating it on every node.

Declaring a parent node as a participant includes all of its descendants. List orders and every node under it joins the flow; add a new child later and it is already included, no edit to the flow file.

A flow is not a call chain. It describes the why — the business process being served — while relations describe the how, what calls what. Both can exist between the same nodes at once. Use a flow when a real-world process spans multiple components and a shared rule applies across them; if you only need to apply a rule to a subset of participants, an aspect can carry a when predicate per attach site.

A key the flow schema does not declare is refused by name, with the key it is probably a typo of, and so is a value of the wrong type. A flow that cannot be loaded — a directory under flows/ with no yg-flow.yaml, or a file that does not parse or breaks the schema — is one yaml-invalid finding for that flow: the flow is left out, and every command still runs on the rest of the graph. Flows are one level deep: each directory directly under flows/ is one flow.

yg-flow.yaml ​

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

KeyTypeRequiredMeaning
namestringyesDisplay name of the business process.
descriptionstringyes (description-missing without it)What the process does.
nodeslist of stringyes, non-empty (or participants)Participant nodes, as paths under model/; each participant's descendants take part too.
participantslist of stringnoAlias of nodes.
aspectslist of string or mappingnoRules every participant carries (channel 5).
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.

Any other key is refused by name, with the key it is probably a typo of.