Skip to content
DarkPrint
What a blueprint isSpecification · 3 of 4
Layer 01 of 03

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.

The file

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.

starter-software-factory/topology.dot38 lines, as the archive stores themdownload
digraph starter_software_factory {
rankdir=LR;
node [shape=box, style=rounded];
// Doc 2 §5.2, five nodes, one phase each.
planner [card="spec-planner@1.0.0"];
builder [card="code-builder@1.0.0"];
tester [card="acceptance-tester@1.0.0"];
debugger [card="targeted-debugger@1.1.0"];
deployer [card="release-gate@1.0.0"];
// The lesson of this blueprint is the edge that is NOT written below.
// Nothing runs from `planner` to `builder`: the acceptance criteria reach the node
// that judges the work and never the node that produces it. Add `planner -> builder`
// and doc 3 §4.1's `criteria-leak` fires on `builder`, taking security from 4 to 2,
// that is doc 2 §5.4's demonstration switch, and it is meant to stay off.
planner -> tester [label="acceptance criteria"];
builder -> tester [label="build"];
// Doc 2 §5.5, the loop is tester -> debugger -> tester and never returns to the
// builder: the work already done is preserved, and the builder stays isolated from
// every fact about the failures for the whole run. The cap lives on the debugger card.
//
// The `condition` on the tester's two exits is what makes that loop terminate. Engine
// spec §3.3 resolves a fork on conditions first and on the spelling of the target id
// last, so while both edges carried a label and nothing else, `debugger` beat
// `deployer` on the fourth character and the factory looped forever without releasing.
// `outcome` is the discriminator because the engine sets it from the tester's own
// status.json on every pass, where a `preferred_label` is optional and this card is
// never told to write one. One key against its own negation is the only shape §10's
// grammar can make total: it has `=` and `!=` and no disjunction, so `partial_success`,
// `retry`, `fail` and a criterion the tester could not evaluate all land on the arm
// that does not ship, and exactly one arm is ever eligible.
tester -> debugger [label="failure evidence", style=dashed, condition="outcome!=success"];
debugger -> tester [label="patch"];
tester -> deployer [label="approved build", condition="outcome=success"];
}

The checks

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
What the validator checks in the DOT layer, and what it leaves to the author
in the filewhat it holdswhat 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 -> testerA 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 wholeWhere 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, commentsWhether 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 neitherDeterministic, 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, styleGraphviz layout. DarkPrint reads none of it, and none of it reaches the file a runner takes.free text
shapeThe 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