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>
  );
}