Appearance
An aspect is one rule the reviewer enforces on your code. You write the rule once; the reviewer checks every change against it before your agent moves on. Aspects are where you say what "correct" means for your codebase — "every mutation logs an audit event", "no UI file imports the database client", "exported classes are PascalCase".
A rule comes in one of three kinds:
- A reviewer rule (
content.md) — a rule written in plain Markdown prose, judged by the reviewer, the model you configure. Use this for anything that takes reading and judgment, the kind of call a human reviewer makes. - A script rule (
check.mjs) — a small check that runs on your machine. Free and identical every time. Use this for mechanical rules a script can decide. - A bundle (only
implies:) — no rule text of its own; it attaches a group of other rules at once and has no verdict of its own. See Bundling rules.
You pick the kind per rule. Most teams use both reviewer rules and script rules.
Anatomy of an aspect
An aspect is a directory under .yggdrasil/aspects/<id>/. Its path under aspects/ is the aspect's ID — a top-level audit-logging/ is audit-logging, a nested logging/audit/ is logging/audit. Inside are two files: yg-aspect.yaml (name and description) and the rule itself.
text
.yggdrasil/aspects/
audit-logging/
yg-aspect.yaml ← name + description
content.md ← the rule, in plain Markdownyaml
# .yggdrasil/aspects/audit-logging/yg-aspect.yaml
name: Audit Logging
description: "Every mutation must emit an audit event"The content.md file is the rule. The reviewer reads it and checks your source against it, so write it the way you'd write a code-review comment — concrete and actionable, not aspirational. It is the only file of the directory the reviewer is shown as the rule: another file you keep beside it (a guidance.md, notes) never reaches the reviewer, though it is folded into the rule's hash, so editing it re-opens the rule's verdicts all the same (The lock). To show the reviewer another file, list it under references:.
markdown
<!-- .yggdrasil/aspects/audit-logging/content.md -->
Every public mutation endpoint must emit an audit event before returning.
Use the shared `auditLog.emit()` utility. Do not build custom audit logic.
The event must include: user ID, action, timestamp, affected resource ID.Specific rules produce reproducible verdicts. "Audit logging should be appropriate and comprehensive" gives the reviewer nothing to check against; the version above tells it exactly what to look for.
Three rule kinds, at a glance
| Kind | Use it for | Cost |
|---|---|---|
Reviewer rule (content.md) | judgment calls a human reviewer would make — "mutations must emit audit events", "this handler validates its input semantically" | one reviewer call per pair per fill, times the tier's consensus (paid); a plain yg check makes no calls |
Script rule (check.mjs) | mechanical rules — forbidden API calls, naming conventions, import restrictions | runs locally, free, identical every run |
Bundle (only implies:) | attaching several rules as one named group | none of its own — each implied rule is checked as its own kind |
You don't set the kind in a config field — it's inferred from which file is present (content.md → reviewer rule, check.mjs → script rule, or neither file plus a non-empty implies: → a bundle, which produces no verdict of its own). Only a reviewer rule is judged by the reviewer; a script rule has no reviewer. A reviewer rule may also ship an optional companion.mjs hook that resolves per-unit companion files — see Reviewers for authoring depth. See Reviewers to write a reviewer rule or a script rule.
Status, at a glance
Every aspect has a status that controls how its results show up. You move a rule along as your confidence grows: draft while you're still writing it (nothing is checked, nothing recorded), advisory once it's ready (failures show as warnings, CI stays green — with one carve-out: a pair whose assembled prompt exceeds the tier's size limit reports prompt-too-large as an error whatever its status, because it can never be verified at all), enforced once you trust it (failures block CI).
Status defaults to enforced. See Aspect Status for the full lifecycle.
A rule on a node type governs every file of that type, so it is the user who admits it: it runs advisory until they do, and yg log add --aspect <id> --ratify --by '<who>' records their admission — see Law on a node type.
A rule keeps its own history
Beside the rule's files sits log.md — the same log a component has, for the same reason. It holds why the rule exists, every change of its status, and every real failure taken into its corpus:
bash
yg log add --aspect no-raw-sql --reason "Written after the outage on the 3rd."
yg log add --aspect no-raw-sql --status enforced --evidence "a month advisory, no false alarms" --reason "Promoted."
yg log read --aspect no-raw-sqlRecording a change of status does not make one: you edit status: in the rule's own file, and the command refuses to record a status the file does not carry, or one the rule already had, so the history never shows a promotion that moved nothing. Where the rule stood before comes from its own log, else from this checkout's memory of it; when neither knows — a rule that never had a status entry, in a fresh checkout — the command does not guess, and records the move as from "an unrecorded status". What justified the move is required, because it is the part nobody can reconstruct later. If you move a rule and record nothing, yg check says so, and the next full fill (yg check --approve) writes the bare fact into that rule's log so the change is not lost. The free --approve --only-deterministic writes no committed file, so it leaves the warning standing instead.
See yg log --aspect.
Two more fields worth knowing
Both are optional, both live in yg-aspect.yaml, and neither is part of a verdict's identity — adding or changing either one re-verifies nothing.
review_by: — a request to re-examine whether the rule still earns its place, written as a plain calendar day (review_by: 2027-01-15). Once the day has passed, yg check raises one warning per rule saying it is running unreviewed. That is all it does: it never blocks a build, never changes a verdict, and never moves the date by itself. Renewing or retiring the rule is a decision its owner makes. A date that is not a real day (2027-02-30) is a blocking error on that rule. Valid on any kind of rule.
errs: — the honest error direction of a script rule, one of over, under, or exact. under means the check only ever fires on a provable violation, so it has no false positives by design; over means it may flag code the rule does not actually forbid; exact means neither. It is a label for readers and for reporting — the waiver inventory uses it to warn when someone waives an under check, which by definition has no false positive to waive. Declaring it on a reviewer rule or a bundle is an error.
Retiring a rule that blocks wrongly
A rule earns its place by catching real mistakes. But a rule can also block the wrong thing — refuse code that was actually fine — and when that happens often, the team stops trusting the rule and starts routing around it. To keep that visible, the rule-health view (yg aspects --health) has a false-block column: for each rule, how many of its past refusals a person later waived (with a yg-suppress marker) or overturned. A refusal that was fixed in the code never counts — only refusals a human decided were wrong. Where the history is thin the number is shown with a plain (thin data) label, never a precise-looking rate.
The count is a coarse read, not a precise verdict, and it can over-count in one rare case: it matches a waiver to a refusal by file and component, not by exact line, so if a waiver for a rule sits on the same file or component as an unrelated refusal from that same rule, that unrelated refusal can be tallied as a false block. Weigh a single hit with that in mind — it is thin-data by design and feeds a human review, never an automatic block.
Think of it as a false-block budget for the whole repo: every enforced rule quietly spends a little of the team's goodwill each time it blocks something that turns out to be fine, and the budget is finite. When it runs tight, the answer is not to raise the ceiling — it is to look at which rules are doing the most wrongful blocking and retire the worst offenders, which frees room for new rules worth adding. This is a judgement a person makes by reading the column, weighing it against what the rule is protecting; nothing in the tool ever retires a rule for you, and the false-block number never blocks a build or shows up as a required action. It is a signal for a periodic human review, in the same spirit as renewing or retiring a rule that has passed its review-by date.
The false-block column reads the inside record — refusals the tool itself made and a person later waived. There is also an outside companion. When a real miss gets written into the incident ledger as a miscalibrated rule (yg incident add --tag wrong-rule --aspect <rule>), the rule-health view surfaces that too, in a wrong-rule column: how many recorded misses a person attributed to this specific rule. It is deliberately labelled thin — an incident is sparse, hand-written testimony, not a measured rate — and a miss recorded without naming a rule stays in the repo-wide count only, never against one rule. Same spirit as the false-block budget: an honest nudge for a human to look, never a gate.
Scope, at a glance
By default an aspect reviews a whole component in one pass — per: node. The reviewer sees all of the component's files together, which is what cross-file rules need ("exactly one file exports this", "a correlation ID propagates across calls").
For rules that hold within a single file on its own ("every handler validates its input"), switch to per: file: one check per file. Use it only when the rule truly is file-local — a per-file reviewer can't see the rest of the component.
You can also narrow which files get reviewed with scope.files. One catch: the moment you write a scope: block at all, per: becomes required inside it — the per: node default applies only when there is no scope: block. Depth is in Reviewers and The Lock.
When something should be an aspect
Create an aspect when both of these hold:
- The same pattern shows up in three or more files.
- A reviewer can actually check it against the code.
The first keeps you from turning a one-off into a rule. The second is the line that matters most. "Every handler logs an audit trail" is a pattern and checkable — good aspect. "Code should be readable" is real, but no reviewer can decide it against source — not an aspect. Things already visible in the code (imports, config) and things that aren't verifiable (pricing, strategy) don't belong here either.
How a rule reaches your code
You attach a rule once, and it can apply to a single component or many. You never copy-paste a rule onto each file by hand. Attach audit-logging to a parent component and every component beneath it inherits it. Attach it to a node type and every component of that type picks it up. The tool computes where each rule lands, and yg context shows you, for any file, which rules apply and where each one came from.
That's all you need day to day. Below is the full list, for when you need it — the seven channels through which a rule can reach a component. This table is the one complete list; other pages link here:
| Channel | A rule reaches a component when… |
|---|---|
| Own | it's listed in the component's own aspects: |
| Ancestor node | a parent component carries it (children inherit) |
| Own type | the component's type declares it as a default |
| Ancestor type | a parent component's type declares it as a default |
| Flow | the component takes part in a flow that carries it |
| Port | the component consumes a port that requires it |
| Implied | another effective rule pulls it in via implies |
A rule the reviewer checks is the same wherever it came from — the component has to satisfy every rule that reaches it. See Nodes for what these rules attach to, and Conditional Aspects for applying a rule to only a subset of components.
Bundling rules (implies)
A rule can pull in others. Declare implies: [other-rule] and every component that gets the first rule automatically gets the implied ones too, recursively. This lets you group several atomic rules under one named bundle — attach the bundle once, and each child rule still produces its own clean verdict. See Reviewers for authoring depth.
Reference files and companion files
A reviewer rule has two ways to bring in supporting material:
- Static references (
references:) — a lookup table, an error-code catalogue, an API contract. Listed inyg-aspect.yaml; the same files go to the reviewer for every unit. Your agent sees them under theread:paths inyg context. - Per-unit companion files (
companion.mjs) — a hook that resolves different files for each unit under review. Use this when each file being reviewed has a unique counterpart in another node — a scenario document paired with its matching test spec, a migration paired with its schema. The hook returns paths; the runner reads the files and injects them into that unit's prompt only. A companion's verdict folds everything it reads to decide, so read narrowly (one file viactx.fs.read). See Reviewers — Per-unit companion files.
The two mechanisms are independent. Static references are identical for every unit; companion files vary per unit. Both count toward the tier's max_prompt_chars prompt-size limit. See Reviewers for authoring depth on both.
Both are for reviewer rules only. A script rule (a rule with a check.mjs) has no reviewer to put supporting material in front of, so references: on one is refused. A script rule installed from a package that needs a value from outside itself takes it through ctx.config: its settings are declared by its package and set by the repository installing it, and reading one makes it part of that rule's verdict. A rule of your own has no settings — ctx.config is empty for it, and a setting written under config: for it is refused — because its check.mjs is yours to edit: write the value there. A yg-aspect.adapt.yaml beside a rule of your own is read and merged like one beside an installed rule, but it can only say what the rule's own yg-aspect.yaml can; edit that file instead.
A rule can also name its companion instead of shipping one beside itself, with a repo-relative companion: path in yg-aspect.yaml:
yaml
companion: tools/pair-scenario-with-spec.mjsThe named module is loaded exactly as a sibling companion.mjs would be, and its content is folded into the verdict the same way — editing it re-opens the verdicts it helped produce. So is every module it imports by a literal relative specifier, followed from module to module: editing a helper the companion imports re-opens the rule's pairs just as editing the companion does. This exists for a rule you did not write: a rule installed from a package cannot know your repository's layout, so companion: in that rule's yg-aspect.adapt.yaml is how you point it at a resolver that does. The path must exist when the graph loads; a missing one is a graph error, not a surprise in the middle of a review.
Organizing rules in directories
A rule's id is its folder path under the rules directory, so ids can nest: logging/audit lives at logging/audit/. A folder with no rule file of its own is just a grouper. Three names are reserved and never read as rules: packages/ directly under the rules directory (installed packages live there), a drills/ folder inside a rule's own folder (its drill corpus), and any folder whose name starts with a dot. Use nesting to keep a growing rule set legible — group related rules under a shared prefix instead of a flat list. Nesting is naming only; it does not make one rule inherit another. What a rule applies to comes from where it's attached (see How a rule reaches your code), never from where its files sit.
Positive and negative rules
A rule can require something to be present ("every handler validates its input") or forbid something ("nothing reaches the data store directly") — that's just how the rule is worded. A powerful shape is a broad negative rule attached to a parent so it applies to every component beneath it, with the one component type that's legitimately allowed to do the forbidden thing carved out — that type carries its own positive rules ensuring it does it correctly. The carve-out is a conditional rule: when: { not: { node: { type: data-access } } }. The pattern is general — "no raw outbound HTTP except the gateway", and so on.
Field reference
yg-aspect.yaml
Generated from the schema the parser enforces — the same table yg schemas read aspect prints.
| Key | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Display name. |
description | string | yes (description-missing without it) | What the rule demands; a reviewer rule's reviewer is told it. |
reviewer | mapping | no | Optional: the kind is inferred from the rule source. |
reviewer.type | llm | deterministic | aggregate | yes, when reviewer: is present | The rule kind; must agree with the rule source (content.md, check.mjs, or neither). |
reviewer.tier | string | no | The reviewer tier of yg-config.yaml a reviewer rule is judged by; the default tier when absent. |
status | draft | advisory | enforced | no | How much the rule's refusals count. Default: enforced. |
review_by | YYYY-MM-DD | no | A review date; once past, yg check warns without blocking. The user's to set. |
errs | over | under | exact | no | A script rule's honest error direction: under means it fires only on provable violations. |
implies | list of string or mapping | no | Rules this one brings with it wherever it applies (channel 7); a rule with implies: and no rule source is a bundle. |
implies[].id | string | yes | The implied rule's id. |
implies[].when | node predicate | no | Imply the rule only where this node predicate holds. |
implies[].status_inherit | strictest | own-default | no | strictest: the implied rule takes the implier's status when that is higher; own-default: it keeps its own. Default: strictest. |
when | node predicate | no | Apply the rule only to nodes where this node predicate holds. |
references | list of string or mapping | no | Supporting files shown to a reviewer rule's reviewer (not allowed on a script rule or a bundle). |
references[].path | string | yes | A repository-relative file. |
references[].description | string | no | What the reviewer should take from it. |
scope | mapping | no | Review granularity (not allowed on a bundle). Default: per: node. |
scope.per | node | file | yes, when scope: is present | One verdict per node, or one per subject file. |
scope.files | file predicate | no | Which of the unit's files the rule looks at (path and content atoms). |
companion | string | no | A repository-relative companion module to use instead of a companion.mjs beside the rule. |
config | mapping of <key> to string, number or boolean | no | Values a package rule's check reads through ctx.config; only the keys its package declares. |
stores_content | boolean | no | false keeps this rule's refusals out of the local refused-content store except for the verdict hash and the reason: no subject file is copied. Set it on a rule that detects secrets, so a refusal does not leave a second copy of the secret on disk. Default: true. |
Retired keys, refused by name with what became of each (yg init --upgrade removes them): id (never read: a rule's id is its directory path under aspects/); language (removed in 5.0.0: a script rule reads each file's language from its extension); stability (removed in 4.0.0); anchors (removed in 4.0.0). Any other key is refused by name, with the key it is probably a typo of.