The topology file (DOT)
The wiring, in one file. A topology is a Graphviz DOT graph: one node per step, one edge per hand-off. DarkPrint adds a few attribute names of its own, and the one every node carries, card, pins the node to the YAML card that describes the step.
One file, and the names DarkPrint adds
DOT attribute values are flat strings. That is why the format splits in two: the graph carries the wiring, and every piece of detail lives in a card beside it. DarkPrint reads a subset of Graphviz DOT. Attractor, StrongDM’s graph runner and the program the compiled file is written for, reads a narrower grammar still and ignores any attribute name it does not reserve. That is what lets DarkPrint’s own card attribute sit in a file Attractor also parses.
Two of the names in that file are DarkPrint’s own and are in no Attractor document: a node’s card, which pins it to the card that describes it, and an edge’s in and out, which name ports the two cards declare. When the folder is compiled for Attractor, cardis copied through unchanged. By then the exporter has used it: the card’s spec becomes the node’s prompt, and its type picks the node’s shape, which is how Attractor chooses a handler, the program that runs the node. The runner then ignores the attribute. in and out are not copied at all; a compiled edge carries only label, condition and weight. The Attractor crosswalk lists every attribute the compiled file gets and where each one comes from.
A topology on its own is not a pipeline. It carries no prompts and neither of the two boundary nodes Attractor requires, so a runner parses it and then refuses to run it. darkprint export <dir> --attractor compiles the graph and its cards into the file a runner takes, and that file opens with a list of everything a blueprint has no way to say. The command ships in the darkprint package, which is not on npm yet; the MCP page says what works today.
Do not write type= on a node. To Attractor, a node’s type attribute overrides the handler its shape would select, so a DarkPrint type such as agentwould be read as a handler name. A card’s type therefore stays inside the YAML, and a DOT that writes type= is reported as attractor/reserved-attribute.
shapeis the attribute that override would replace. Attractor picks the handler that runs a node from the node’s shape, so in a compiled file the shape decides what the node does. In a topology, shapeis only a drawing hint: DarkPrint ignores it, and the exporter writes each node’s shape from its card’s type.
What the validator checks in the DOT layer
- an error code refuses the bundle
- a warning is reported and the bundle loads
- free text is shown and checked by nothing
| in the file | what it holds | what holds it |
|---|---|---|
| digraph name { … } | The whole file. One directed graph per blueprint. A graph that is not directed stops there. Every rule below reads which way an edge points. | dot/parse-errordot/not-directedrefuses the bundle |
| builder [card="id@version"] | Which card this node is an instance of, by id and exact version. The one DarkPrint attribute every node carries. A version that is not exact is refused, so two readers of the file always get the same card. | bundle/missing-cardbundle/unpinned-cardrefuses the bundle |
| [digest="sha256:…"] | Optional integrity check. A card's digest is the SHA-256 hash of its contents; write the first characters of it here, with or without the sha256: prefix, and the blueprint is refused if the card it names has changed. | bundle/digest-mismatchrefuses the bundle |
| planner -> tester | A hand-off between two ports. The resolver, the part of the validator that wires edges, pairs one of the source card's outputs with one of the target card's inputs by data type; an edge with no compatible pair is refused. | bundle/type-mismatchbundle/missing-dependencyrefuses the bundle |
| [out="criteria", in="criteria"] | Which output and which input the edge joins, by port name. Optional when only one pairing type-checks; when several do, the resolver warns (bundle/port-ambiguous) and asks for these. A name neither card declares is refused. | bundle/port-mismatchrefuses the bundle |
| the graph as a whole | Where a run enters, where it ends, and whether every node can be reached from an entry point. | bundle/no-entrybundle/no-exitbundle/unreachable-nodereported, still loads |
| ids, commas, comments | Whether Attractor's stricter grammar can read the file. DarkPrint accepts more Graphviz than Attractor does (a # comment, a hyphen in a node id, attributes separated by a semicolon or a space), so a file DarkPrint loads can still be refused by a runner. Parsing is not running: the file a runner takes is what darkprint export --attractor compiles out of the graph and its cards. | attractor/bad-node-idattractor/attr-separatorattractor/hash-commentreported, still loads |
| [label="acceptance criteria"] | What the author says the edge carries. DarkPrint compares it against nothing; the two port types decide what may travel. Attractor reads it a second way: the export copies the label into the compiled file, and Attractor spec §3.3 matches it, normalised, against the branch name a stage asks for, on edges that have no condition. | free text |
| [condition="outcome=success"] | Attractor's condition on the edge, carried into the compiled file exactly as the author spelled it and parsed by nothing here. Spec §3.3 tries the conditional edges out of a node first: when any condition is true the run takes the one with the highest weight among them and never looks at the unconditional ones. Every computed score on this site counts a conditional edge as a path that can be taken. | free text |
| [weight=10] | Numeric priority. Higher wins and the default is zero. Spec §3.3 reaches it fourth among the unconditional edges, after the conditions, the branch name the previous step asked for (if any) and the node ids it suggested, and it also ranks two conditions that are both true. DarkPrint carries the number and compares no two of them. | free text |
| a fork with neither | Deterministic, and rarely what the author meant. With no condition, no branch name asked for and no suggested id, spec §3.3 falls through to weight, and on equal weights it takes, as a last resort, the edge whose target node id comes first lexicographically. Two bare edges out of one node are a branch decided by the spelling of the node names. | free text |
| rankdir, style | Graphviz layout. DarkPrint reads none of it, and none of it reaches the file a runner takes. | free text |
| shape | The handler selector. Attractor spec §2.8 maps each shape to the handler that executes the node: a node with no shape is a box, which runs as an LLM step, and only an explicit type= outranks the shape. DarkPrint reads no shape out of a topology; the exporter writes each node's shape from its card's type. | free text |