Outranks DAG and docs-invariants CI
petrova advanced 8 min read
ELI5
Every doc carries a tag saying which other docs it outranks. The CI builds the directed graph of those tags and refuses to merge if it finds a loop. This is what stops backlog notes from quietly overruling north-star intent.
Technical Deep Dive
Two DAGs, one CI workflow
docs-invariants.yml enforces two independent DAGs and four other invariants:
flowchart TB subgraph CI[".github/workflows/docs-invariants.yml"] iso["Validate ISO dates in decision-doc filenames (MR-4)"] fnd["Validate findings filenames YYYYMMDD[-HHMM]-<slug>.md (MR-4)"] ax["Validate AGENTS.xml schema — required children, event catalog (MR-6)"] hg["AGENTS.xml handoff graph is a DAG (intra-phase only — exempt: audit.fail, phase.close)"] rg["Rank graph from outranks: front-matter is a DAG (MR-1)"] xref["Milestone-ID cross-refs in AGENTS.xml exist in MILESTONES.md (MR-8)"] sup["Superseded docs link to successor (MR-7)"] end trigger["pull_request to docs/, AGENTS.xml, MILESTONES.md, CLAUDE.md"] trigger --> CI CI --> verdict{all pass?} verdict -->|yes| merge["mergeable"] verdict -->|no| block["block"]The rank graph (MR-1)
Every doc in docs/ declares its tier and what it outranks:
---rank: north-staroutranks: [decisions, findings, runbooks, backlog]---The CI loads every .md under docs/, parses front-matter, and adds an edge (rank → lower) for each entry in outranks. Then:
import networkx as nxg = nx.DiGraph()# ... add edges ...if not nx.is_directed_acyclic_graph(g): cycle = list(nx.simple_cycles(g))[:1] print(f"MR-1 violation: rank graph has a cycle: {cycle}") sys.exit(1)Early-state escape hatch: “If g.number_of_nodes() == 0, print ‘No ranked docs found yet (early project state); skipping DAG check.’ and exit 0.” So a fresh repo doesn’t fail before any front-matter exists.
The handoff graph (AGENTS.xml)
The agent handoff graph is also required to be a DAG, but with two principled exemptions:
| Trigger | Reason exempt |
|---|---|
audit.fail | legitimate remediation back-edge: auditor → implementer |
phase.close | legitimate phase-boundary loop: reviewer → planner |
Both close real loops in the workflow but neither is an intra-phase forward-progress cycle. The CI filters them out, then DAG-checks what remains:
intraphase_exempt = {'audit.fail', 'phase.close'}forward = nx.DiGraph()for u, v, d in g.edges(data=True): if d.get('trigger') not in intraphase_exempt: forward.add_edge(u, v)if not nx.is_directed_acyclic_graph(forward): sys.exit(1)Filename validators (MR-4)
Two distinct grammars, both enforced:
# decision docs[[ "$base" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+\.md$ ]]# findings[[ "$base" =~ ^[0-9]{8}(-[0-9]{4})?-[a-z0-9-]+\.md$ ]]Decisions use hyphenated dates, findings use compact YYYYMMDD plus optional HHMM. Both refuse uppercase, refuse non-kebab slugs.
Cross-reference validator (MR-8)
Every milestone ID referenced in AGENTS.xml must exist in MILESTONES.md:
declared = set(re.findall(r'\bM\d+(?:\.\d+){1,2}\b', milestones_text))referenced = set(re.findall(r'\bM\d+(?:\.\d+){1,2}\b', agents_text))referenced = {r for r in referenced if not r.startswith('M{')} # templates exemptmissing = referenced - declaredM{N}.{n} placeholders (template form) are filtered out — only resolved IDs are checked. This means the template can ship with M{N}.{n} and not fail CI; instantiated consumer repos must use real IDs.
AGENTS.xml schema validator (MR-6)
Each <subagent> requires the six children: name, spawns_on, reads, writes, output_contract, refusal_conditions. Each <spawns_on> must contain <event> children whose IDs match <event_catalog>. <refusal_conditions> must have at least one <condition> (“subagents that ‘never refuse’ are a smell” — MR-6 spirit).
How adding a new doc category interacts with the DAG
Adding a new tier (say roles between findings and backlog) means:
- Pick the rank name and place in the hierarchy.
- Decide what it outranks (must not introduce a cycle).
- Update one or more existing docs’
outranks:lists if higher-tier docs should outrank the new tier. - Open a decision doc explaining the new tier (MR-1 implies tier additions earn a doc).
The CI re-runs on the PR; the DAG check catches accidental cycles immediately.
Failure surfaces
When CI fails, the failure message names the MR being violated:
- “MR-4 violation: docs/decisions/foo.md does not match YYYY-MM-DD-
.md” - “MR-1 violation: rank graph has a cycle: [[‘decisions’, ‘north-star’, ‘decisions’]]”
- “AGENTS.xml: subagent X: <refusal_conditions> empty (MR smell)”
- “MR-7 violation: docs/decisions/old.md status=superseded but no Superseded-by link”
The Summary step at the end prints: “If this job failed, see META-RULES.md for the rule (MR-N) being enforced.”
Key Terms
- Rank-graph DAG — directed graph of
(rank → lower-tier)edges fromoutranks:front-matter; required acyclic per MR-1. - Handoff-graph DAG (intra-phase) — directed graph of
<edge from=... to=... trigger=...>in<handoff_graph>, filtered to non-loop triggers, required acyclic. - Template-form M-ID — placeholder like
M{N}.{n}in AGENTS.xml.tmpl; filtered out before milestone cross-ref check.
Q&A
Q: Which two AGENTS.xml handoff triggers are exempt from the intra-phase DAG check?
A: audit.fail (auditor → implementer remediation) and phase.close (reviewer → planner phase-boundary). Both close legitimate loops; the DAG check filters them before testing acyclicity.
Q: What does the rank-graph CI step do when zero ranked docs exist?
A: Prints “No ranked docs found yet (early project state); skipping DAG check.” and exits 0. This is the bootstrap escape hatch — a fresh repo with no rank: front-matter yet shouldn’t fail CI before any tier is declared.
Q: What pattern flags an MR-4 violation in a decision-doc filename?
A: Anything that does not match ^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+\.md$. Uppercase letters, missing date, non-kebab slug, or wrong date separators all fail. _template.md, README.md, and _changelog.md are exempt; everything else under docs/decisions/ is checked.
Examples
A PR adds docs/decisions/2026-05-06-Cool-Feature.md (uppercase). CI fails: “MR-4 violation: docs/decisions/2026-05-06-Cool-Feature.md does not match YYYY-MM-DD-2026-05-06-cool-feature.md; CI passes. Separately, a planner adds M9.1.1 to AGENTS.xml without first writing it into MILESTONES.md — the milestone-cross-ref step fails with “AGENTS.xml references milestone IDs not in MILESTONES.md: [‘M9.1.1’]”. Author lands MILESTONES.md edit in the same commit; CI passes.
neighbors on the map
- AuditTrail & Selective Disclosure implementing a compliance export that must only reveal disclosed audit entries
- eva doctor Validation Rules diagnosing a doctor FAIL line
- Workspace Registry YAML Schema adding a workspace row to registry.yaml