ADR repo structure
content/canvases/adr-repo-structure.canvas.tsx
import {
Callout,
Card,
CardBody,
CardHeader,
Code,
Divider,
Grid,
H1,
H2,
H3,
Stack,
Stat,
Table,
Text,
computeDAGLayout,
useHostTheme,
useMemo,
} from "cursor/canvas";
const LABELS: Record<string, string> = {
calculation: "Calculation",
transactions: "Transactions",
superposition: "Superposition",
ui: "UI",
portfolio: "Portfolio returns",
database: "Database",
navs: "NAV history",
authentication: "Authentication",
};
const CONTAINMENT = [
["calculation", "transactions"],
["calculation", "superposition"],
["ui", "portfolio"],
["database", "navs"],
] as const;
const CROSS = [
["superposition", "portfolio", "informs"],
["superposition", "navs", "depends-on"],
] as const;
/**
* Sample topic graph: solid edges are the category tree, dashed edges are
* cross-links a folder tree cannot store.
*/
function TopicGraph() {
const theme = useHostTheme();
const layout = useMemo(
() =>
computeDAGLayout({
nodes: Object.keys(LABELS).map((id) => ({ id })),
edges: [
...CONTAINMENT.map(([from, to]) => ({ from, to })),
...CROSS.map(([from, to]) => ({ from, to })),
],
direction: "horizontal",
nodeWidth: 148,
nodeHeight: 36,
rankGap: 92,
nodeGap: 28,
padding: 8,
}),
[],
);
const crossKey = new Set(CROSS.map(([from, to]) => `${from}->${to}`));
const crossLabel = new Map(CROSS.map(([from, to, label]) => [`${from}->${to}`, label]));
return (
<Stack gap={8}>
<div style={{ overflowX: "auto" }}>
<svg
width={layout.width}
height={layout.height}
role="img"
aria-label="Sample ADR topic graph. Calculation contains Transactions and Superposition. Superposition informs Portfolio returns and depends on NAV history."
>
{layout.edges.map((edge) => {
const key = `${edge.from}->${edge.to}`;
const dashed = crossKey.has(key);
const midX = (edge.sourceX + edge.targetX) / 2;
const midY = (edge.sourceY + edge.targetY) / 2;
const label = crossLabel.get(key);
return (
<g key={key}>
<line
x1={edge.sourceX}
y1={edge.sourceY}
x2={edge.targetX}
y2={edge.targetY}
stroke={dashed ? theme.accent.primary : theme.stroke.primary}
strokeWidth={dashed ? 1.5 : 1}
strokeDasharray={dashed ? "4 3" : undefined}
/>
{label ? (
<text
x={midX}
y={midY - 6}
textAnchor="middle"
fill={theme.accent.primary}
fontSize={10}
>
{label}
</text>
) : null}
</g>
);
})}
{layout.nodes.map((node) => {
const child = node.rank > 0;
return (
<g key={node.id}>
<rect
x={node.x}
y={node.y}
width={148}
height={36}
rx={6}
fill={child ? theme.bg.elevated : theme.fill.tertiary}
stroke={node.id === "superposition" ? theme.accent.primary : theme.stroke.secondary}
/>
<text
x={node.x + 74}
y={node.y + 23}
textAnchor="middle"
fill={theme.text.primary}
fontSize={12}
>
{LABELS[node.id]}
</text>
</g>
);
})}
</svg>
</div>
<Text size="small" tone="tertiary">
Solid lines are the browse tree (a topic contains its children). Dashed lines are typed links. Superposition lives under Calculation and still informs a UI topic and depends on a database topic.
</Text>
</Stack>
);
}
export default function AdrRepoStructure() {
return (
<Stack gap={24}>
<Stack gap={8}>
<H1>ADR repo structure for a decision graph</H1>
<Text>
Keep one markdown file per decision, give every file one home in a category tree, and store every cross-link in YAML. Nested folders cannot do the second job: a file has only one parent directory.
</Text>
</Stack>
<Grid columns={3} gap={16}>
<Stat value="1 topic" label="Home of each decision in the tree" tone="info" />
<Stat value="ADR-NNNN" label="Global id, never reused or renumbered" />
<Stat value="5 link types" label="How a node points at any other node" />
</Grid>
<Stack gap={8}>
<H2>The graph folders cannot store</H2>
<TopicGraph />
</Stack>
<Grid columns="1.1fr 0.9fr" gap={20}>
<Stack gap={8}>
<H2>Repo shape</H2>
<Text>
Topics are the map. Decisions are the choices. The catalog is generated from both, so the index cannot drift from the files.
</Text>
<Card>
<CardHeader>xylar-adr/</CardHeader>
<CardBody>
<Text size="small" style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", whiteSpace: "pre" }}>
{`README.md
taxonomy.yml
templates/decision.md
decisions/
ADR-0001-record-decisions-here.md
ADR-0012-xirr-external-cash-only.md
catalog/
index.md
graph.md`}
</Text>
</CardBody>
</Card>
</Stack>
<Stack gap={8}>
<H2>Two layers</H2>
<H3>Tree, for browsing</H3>
<Text>
<Code>taxonomy.yml</Code> lists topics and a single parent. Calculation contains Transactions and Superposition. UI, Database, and Authentication are siblings. Add a topic before the first decision that lives there.
</Text>
<H3>Links, for relationships</H3>
<Text>
Each decision names exactly one <Code>topic</Code>. Anything else — a UI screen this calculation changes, a NAV table it depends on, an older decision it replaces — is a typed link, not a second parent.
</Text>
<Callout tone="info">
MADR’s own category decision (ADR-0010) allows one folder level and rejects nesting. That is the right call for a flat log. It is the wrong source of truth once a grandchild must point at another branch.
</Callout>
</Stack>
</Grid>
<Stack gap={8}>
<H2>Link types</H2>
<Text size="small" tone="tertiary">
Direction is stored once. The inverse is derived when the catalog is built, the same way adr-tools and AdrMcp keep both ends of a link.
</Text>
<Table
headers={["Type", "Meaning", "Inverse", "Xylar example"]}
rows={[
["supersedes", "This decision replaces an older one", "superseded-by", "A new XIRR rule replaces the previous cash treatment"],
["depends-on", "This decision is false if the target is false", "required-by", "Superposition XIRR depends on the NAV date convention"],
["informs", "This decision changes how another area should behave", "informed-by", "External-cash XIRR informs the portfolio returns screen"],
["constrains", "The target must stay inside this rule", "constrained-by", "Auth session rules constrain the admin view-as flow"],
["related-to", "Same concern, no direction", "related-to", "Two UI decisions about the same household switcher"],
]}
striped
/>
</Stack>
<Stack gap={8}>
<H2>What was compared</H2>
<Text size="small" tone="tertiary">
Sources: MADR ADR-0010 (categories), Nygard 2011 (immutable status), log4brains, adrs, AdrMcp, ArchMan traceability. Compared October 2026.
</Text>
<Table
headers={["Approach", "Child topics", "Cross-links", "Stable ids", "Verdict"]}
columnAlign={["left", "left", "left", "left", "left"]}
rowTone={["danger", "warning", "success", "warning", "neutral"]}
rows={[
[
"Nested folders",
"Yes, by directory depth",
"No. A file has one parent",
"Local numbers break when a file moves",
"Reject",
],
[
"MADR: one folder level, local ids",
"One level only. Nesting is explicitly out",
"Only by prose links inside the file",
"No repo-wide id. MADR lists this as a con",
"Right for a simple log, not this graph",
],
[
"Flat files, taxonomy.yml, typed links",
"Yes. The tree is data, not directories",
"Yes. Any topic or ADR can point at any other",
"ADR-NNNN stays put if you recategorize",
"Use this",
],
[
"Tags only",
"No parent/child",
"Tags do not say how two areas relate",
"Yes, if ids are global",
"Loses Calculation → Transactions",
],
[
"Notion or a graph database",
"Yes",
"Yes",
"Yes",
"Git stays the source. Agents and review already read the repo",
],
]}
/>
</Stack>
<Grid columns="1fr 1fr" gap={20}>
<Card>
<CardHeader trailing="accepted">ADR-0012 front matter</CardHeader>
<CardBody>
<Text size="small" style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", whiteSpace: "pre-wrap" }}>
{`id: ADR-0012
title: Portfolio XIRR counts external cash only
status: accepted
date: 2026-10-05
topic: superposition
links:
- type: informs
target: topic:portfolio-returns
- type: depends-on
target: ADR-0009
code_refs:
- repo: xylar-mvp
path: xylar-backend-api/src/queues/xylar-superposition/xylar-superposition.static.ts`}
</Text>
<Divider />
<Text size="small" tone="secondary">
Body follows the short MADR template: context, drivers, options considered, outcome, consequences. Status is proposed, accepted, deprecated, or superseded.
</Text>
</CardBody>
</Card>
<Stack gap={8}>
<H2>Rules that keep the graph true</H2>
<Text>
An accepted decision is not edited into a new decision. Write the replacement, set its <Code>supersedes</Code> link, and mark the old file superseded. The old text stays so the reason for the change remains readable.
</Text>
<Text>
<Code>code_refs</Code> point at paths in xylar-mvp. That is how a later review finds the superposition or auth code a decision governs, and how a stale reference shows up when the file moves.
</Text>
<Text>
<Code>catalog/</Code> is generated. Hand-editing the index is how links go missing. log4brains and adr-tools both treat the markdown files as the record and the site or graph as a view.
</Text>
<Callout tone="warning">
Do not number decisions inside each category. MADR tried local ids and recorded the downside: there is no single number that identifies a decision once categories exist.
</Callout>
</Stack>
</Grid>
<Stack gap={6}>
<H3>Where this comes from</H3>
<Text size="small" tone="tertiary">
MADR template and ADR-0010, Support Categories (adr.github.io/madr). Michael Nygard, Documenting Architecture Decisions (2011): one decision per record, status lifecycle, supersede instead of rewrite. log4brains: markdown in git, generated site, numbering optional to avoid merge pain. joshrotenberg/adrs: tags, typed links, Graphviz. AdrMcp: relationship graph, inverse links, code reference checks. ArchMan ADR catalog: impact area plus bidirectional traceability.
</Text>
</Stack>
</Stack>
);
}