The Attractor crosswalk
One page for the reader who already knows Attractor, StrongDM's graph runner and the program the file darkprint export writes is written for. Which card field becomes which node attribute, which node type selects which handler, and every reserved name a runner reads that a blueprint has no way to set. New to both? Start with the topology and the node card and come back.
- checked against
- attractor-spec.md
- sha256
- 235354496e2bc35cba9822cded2ebd71ff35a8fb7fce4e51f52b2788586e92ec
- bytes
- 93036
- upstream commit
- fb57a55ed97372a27ac90102f436947e29f48426
- spec last changed on
- 2026-03-17
- spec revision checked on
- 2026-09-05
Every attribute a bundle writes into the compiled file
A DarkPrint blueprint is a topology in DOT, one YAML card per node, and a vocabulary the two resolve against. darkprint export <dir> --attractor compiles those into a single DOT file. The tables below are what it writes, read off the exporter’s own constants rather than typed, so they cannot drift from the file.
The last two node rows are DarkPrint’s own. card pins the exact card version a node is an instance of, and dp_noderecords an id the grammar forced the exporter to rewrite. Neither name is in §2.5, §2.6 or §2.7, neither is in any of Appendix A’s three tables, and none of §7.2’s built-in lint rules is about an attribute name those tables do not carry. So a runner parses them, finds nothing that reads them, and runs the pipeline anyway, while a DarkPrint reader gets the pin back out of the same file.
The right-hand column is computed rather than typed: each row is checked against the 46 names Attractor reserves across graph, node and edge. If a later revision of the specification reserves card or dp_node, that column will say so.
on a graph
The compiled file writes 2 attributes here, all of them carrying Attractor's own meaning. Not every one is written every time: a row that can be missing says so under "when absent".
| in the compiled file | read from | what a runner does with it | in the spec |
|---|---|---|---|
| goal | manifest.summaryThe bundle manifest's one-line summary. | §2.5: “Human-readable goal for the pipeline. Exposed as $goal in prompt templates and mirrored into the run context as graph.goal.” Every prompt on the graph can therefore expand it.when absent: A manifest with a blank summary writes no goal attribute at all. | reserved§2.5 · §4.5 |
| label | manifest.titleThe bundle manifest's title. | §2.5: “Display name for the graph (used in visualization).” Nothing routes on it.when absent: A manifest with a blank title writes no label attribute. | reserved§2.5 |
on a node
The compiled file writes 9 attributes here, 7 carrying Attractor's own meaning and 2 carrying DarkPrint's, which a runner reads as nothing. Not every one is written every time: a row that can be missing says so under "when absent".
| in the compiled file | read from | what a runner does with it | in the spec |
|---|---|---|---|
| label | card.nameThe card's human name. A node with no card is labelled with its own DOT id. | §2.6: “Display name shown in UI, prompts, and telemetry.” §4.5 also falls back to it as the prompt when a codergen node has none, so it is a routing-visible string and not only a caption. | reserved§2.6 · §4.5 |
| shape | card.typeThe card's ontology type, resolved through its broader chain and then through the type table below. | §2.6: “Graphviz shape. Determines the default handler type.” §2.8 is the mapping, and it is the whole reason a DarkPrint type never travels as a DOT attribute.when absent: A type the vocabulary has never heard of, and a node with no card at all, both get box. That runs the card's spec as a prompt instead of dropping the node. | reserved§2.6 · §2.8 |
| prompt | card.specThe card's spec, verbatim. This is the field that makes a topology runnable. | §2.6: “Primary instruction for the stage. Supports $goal variable expansion. Falls back to label if empty for LLM stages.” §4.5 step 1 is that fallback in pseudocode. | reserved§2.6 · §4.5 |
| llm_model | card.modelThe model the card names, quoted as a String because a dash ends a bare token. | §2.6: “LLM model identifier. Overridable by stylesheet.” §8.5 puts an explicit node attribute above every stylesheet rule.when absent: A card naming no model writes no attribute, so the graph's model_stylesheet still decides. Writing an empty string here would beat the sheet under §8.5 rather than defer to it. | reserved§2.6 · §8.4 · §8.5 |
| max_retries | card.params.max_iterationscard.params.maxIterationscard.params.max_retriesThe iteration cap the card declares, read by the same function that decides whether a cycle is counted as unbounded. | §2.6: “Number of additional attempts beyond the initial execution. If omitted, inherits graph default_max_retries. max_retries=3 means up to 4 total executions.” So a cap of 3 is four passes, not three.when absent: A card declaring no cap writes no attribute, and the node inherits the graph's default_max_retries, which is 0. | reserved§2.6 · §3.5 · §3.6 |
| tool_command | card.params.tool_commandThe shell command a shell-tool card carries. Written only when the node's shape selects §4.10's handler, since no other handler reads it. | §4.10 reads it bare and refuses the node without it: “IF command is empty: RETURN Outcome(status=FAIL, failure_reason=‘No tool_command specified’)”. It has no default anywhere in the spec.when absent: A shell-tool card with no command writes no attribute. That is deliberate: an empty string is the value §4.10 fails on, so a missing command is left out of the file, and the validator has already warned about it as card/missing-field. | reserved§4.10 |
| class | card.typecard.phasesThe card's type, then its broader chain nearest-first, then its phases in the card's own order, each lowercased and hyphenated behind a dp- prefix. | §2.12: “Classes are comma-separated.” §8.3 makes .class_name a stylesheet selector at specificity 2, above a shape and below a node id, which is what lets whoever runs the file choose a model per class; DarkPrint itself states no opinion on which model a class should run.when absent: A card with no phases and an unknown type still writes its declared type as one class. | reserved§2.10 · §2.12 · §8.2 · §8.3 |
| card | card.idcard.versionThe pinned card ref, id@version, joined the way a DOT node already pins one. | Nothing. It is in none of §2.5, §2.6 or §2.7, in none of Appendix A's three tables, and no §7.2 lint rule is about an attribute name the tables do not carry. This one row is the compatibility claim. | not reserved§2.5 · §2.6 · §2.7 · §7.2 · Appendix A |
| dp_node | not a card fieldThe node's original id in topology.dot, written only when the id had to be rewritten to reach the file. | Nothing, for the same reason as card. It exists because §2.3 requires “bare identifiers for node IDs” matching [A-Za-z_][A-Za-z0-9_]*, and because a node called start or exit collides with §7.2's start_node and terminal_node rules. The rewritten node keeps its real name here. | not reserved§2.2 · §2.3 · §7.2 |
on an edge
The compiled file writes 3 attributes here, all of them carrying Attractor's own meaning. Not every one is written every time: a row that can be missing says so under "when absent".
| in the compiled file | read from | what a runner does with it | in the spec |
|---|---|---|---|
| label | not a card fieldThe label the edge carries in topology.dot, trimmed. | §2.7: “Human-facing caption and routing key. Used for preferred-label matching in edge selection.” §3.3 is where that matching happens.when absent: An unlabelled edge writes no label attribute, and its two siblings below are unaffected. | reserved§2.7 · §3.3 |
| condition | not a card fieldThe condition the edge declares, carried byte for byte and never trimmed. It is input to somebody else's parser. | §2.7: “Boolean guard expression evaluated against the current context and outcome.” §10 is the grammar it has to parse under, and §7.2's condition_syntax rule is an ERROR when it does not.when absent: An edge with no condition is unguarded and always eligible. | reserved§2.7 · §7.2 · §10 |
| weight | not a card fieldThe weight the edge declares. Emitted bare when it looks like a number under §2.2's Integer or Float, quoted otherwise, and both spellings parse back the same. | §2.7: “Numeric priority for edge selection. Higher weight wins among equally eligible edges.”when absent: An edge with no weight takes §2.7's default of 0. | reserved§2.2 · §2.7 · §3.3 |
Which type selects which handler
§2.6 makes shapethe handler selector and §2.8 is the mapping. A DarkPrint card’s type is an ontology term inside the YAML and never a DOT attribute, so this table is the only place the two vocabularies meet. A type a blueprint declares in its own namespace is mapped through its parent types (its broader chain), so a term only one publisher uses still lands on a handler.
More than one type lands on 2 of these shapes, and that loses information on the way back: a pipeline imported from Attractor with no class attribute to read comes home as an agent where it may have meant a tool or a validation node. That is why the class attribute is written at all; the next section covers it.
tool maps to box and §4.5’s codergen handler, not to parallelogram. A DarkPrint toolcard carries a prose spec, the MCP servers it may reach and a skill file, and has never carried a shell command, so §4.10’s handler would have failed it on sight. The type that maps to parallelogram is shell-tool, and it is the one that carries a command in params.tool_command.
| card type | shape | handler (§2.8) |
|---|---|---|
| agent | box | codergen |
| tool | box | codergen |
| shell-tool | parallelogram | tool |
| human-gate | hexagon | wait.human |
| human-input | hexagon | wait.human |
| decision | diamond | conditional |
| validation | box | codergen |
| parallel | component | parallel |
| parallel.fan-in | tripleoctagon | parallel.fan_in |
| manager-loop | house | stack.manager_loop |
| __start (added by the exporter) | Mdiamond | start |
| __exit (added by the exporter) | Msquare | exit |
Why every emitted class starts with dp-
§2.10 derives a class from a subgraph’s label “by lowercasing the label, replacing spaces with hyphens, and stripping non-alphanumeric characters (except hyphens)”, so subgraph { label="Agent" } yields the class agent. A bare agentemitted from a card’s type would be the same string on a different set of nodes, with nothing in §8.3 able to tell the two apart. The prefix removes that collision.
The cost: a model_stylesheet rule written for Attractor’s own .agent matches none of these nodes, and a rule aimed at a DarkPrint class has to name .dp-agent. Without the prefix, a subgraph somebody added for layout could silently move every agent in the pipeline onto a different model.
§2.12 makes the list comma-separated and §8.2’s ClassName ::= [a-z0-9-]+ is what a name has to survive, which is why / and .collapse to a hyphen. The classes below are computed from real cards with the exporter’s own function.
| shape | types that select it | a card in this archive | class= |
|---|---|---|---|
| box | agent, tool, validation | a2a-delegate@1.0.0 | dp-tool |
| parallelogram | shell-tool | sandboxed-python-runner@1.0.0 | dp-shell-tool,dp-tool |
| hexagon | human-gate, human-input | confidence-escalation@1.0.0 | dp-human-gate,dp-human-in-the-loop |
| diamond | decision | capability-matcher@1.0.0 | dp-decision,dp-evaluative |
| component | parallel | panel-fanout@1.0.0 | dp-parallel,dp-orchestration |
| tripleoctagon | parallel.fan-in | panel-fan-in@1.0.0 | dp-parallel-fan-in,dp-orchestration |
| house | manager-loop | research-supervisor@1.0.0 | dp-manager-loop,dp-orchestration,dp-planning |
The type comes first, then its broaderancestors nearest-first, then the card’s phases. The ancestors are there because a rule written for every agent should catch a local type subsumed under agentby an archive the rule’s author has never seen. A card with no phase declares none, and a node with no phase class is not a node missing one.
What a blueprint has no way to say
The compatibility claim is about the format: an Attractor runner accepts this file. It is not a claim that a blueprint can say everything a pipeline can. Attractor reads 46 reserved names across graph, node and edge; this exporter writes 12of them with Attractor’s own meaning. The other 33 are listed below, and leaving one unset is not an error: a goal_gate nobody wrote is a gate that never fires.
One name is in neither list, and it is a decision rather than a gap. Node typeis §2.6’s explicit handler override, which “takes precedence over shape-based resolution”. A DarkPrint node’s type is an ontology term inside the card and it reaches the runner as shape, so writing typeas well would override the handler that shape just selected. Announcing it as unexpressed would say the node’s type was dropped on the way out, which is the opposite of what happens to it, and a DOT that does write it is reported as attractor/reserved-attribute.
The names fall into two groups. Most have a documented fallback: a blueprint that says nothing gets the runner’s own default. Three names in the specification have none at all: §4.10 returns FAIL on an empty tool_command, §6.5 sends a wait.human node round again rather than choosing without a human.default_choice, and §4.11 hands stack.child_dotfile to the child launcher with no default at all. The exporter writes the first of those for a shell-tool card, leaving 2 that a blueprint still cannot say.
Every file darkprint export --attractor writes opens with these same two lists. The file will be read on a machine that has neither this page nor the source, so what a blueprint cannot carry has to travel with it. The command ships in the darkprint package, which is not on npm yet; the MCP page says what works today.
falls back to the runner’s own default
- graph
- model_stylesheet, default_max_retries, default_max_retry, default_fidelity, retry_target, fallback_retry_target, stack.child_workdir, tool_hooks.pre, tool_hooks.post
- node
- goal_gate, retry_target, fallback_retry_target, fidelity, thread_id, timeout, llm_provider, reasoning_effort, auto_status, allow_partial, join_policy, max_parallel, manager.poll_interval, manager.max_cycles, manager.stop_condition, manager.actions, stack.child_autostart, tool_hooks.pre, tool_hooks.post
- edge
- fidelity, thread_id, loop_restart
required by a handler, with no default
- graph
- stack.child_dotfile
- node
- human.default_choice
Three names in the specification are read by a handler with no fallback: stack.child_dotfile (graph), tool_command (node), human.default_choice (node). tool_command is not in the list above because the exporter writes it for shell-tool cards; the other two a blueprint still cannot set.
A topology is not a pipeline
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 had no way to say.
§7.2 is where the refusal is written down. start_node and terminal_nodeare both ERROR and both require exactly one, and §7.1 says a runner “must refuse to execute a pipeline with error-severity diagnostics”. reachability is a third ERROR, which is why the exporter adds an edge from __startto every node a walk from the entry would otherwise miss. A topology also has no prompts, so every node would raise §7.2’s prompt_on_llm_nodes warning even if the boundary were there.
Both artefacts are DOT and only one of them runs. The topology is the file a reader edits and a reviewer reads, and it is where the wiring lives; the compiled file is what a runner takes, and it is where the prompts, the models and the boundary appear. They are checked in different places for that reason: the topology pagelists the diagnostics the validator raises about the first, and the compiled file’s own header carries the two lists above.