Skip to content

yaml ​

github.com/Luo-root/pulse/yaml assembles YAML into a reusable pulse.Graph and a run-input plan, SeedPlan. It also offers static analysis without a host registry. This page references APIs, fields and assembly contracts. For a step-by-step tutorial, see the declarative assembly guide.

YAML declares the topology. Host-registered named factories supply only a node's pulse.RunFunc; they do not return a Node or decide its wiring. This package provides YAML entry points, with no separate JSON entry point.

The dependency direction is pulse ← yaml. This package depends on the root pulse package, gopkg.in/yaml.v3 and the standard library. The root package itself depends only on the standard library.

Version and installation ​

v0.5.1 requires Go 1.26.0 or later, aligned with Durable. v0.5.0's original Go 1.25.0+ requirement applies only to the historical release. This page uses v0.5.1 as its API baseline; pin that version when installing. For development or unmerged changes, select main or matching local source as described in installation:

bash
go get github.com/Luo-root/pulse@v0.5.1

Complete example ​

This program embeds usable YAML and registers one []string input key and one counting factory. It needs no files, environment variables or external services. A successful run prints count=3. Registration, Load, Inputs and Run errors are explicitly checked.

go
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/Luo-root/pulse"
	pulseyaml "github.com/Luo-root/pulse/yaml"
)

const document = `
version: 1
description: Count a literal list
seeds:
  - key: {name: items, type: "[]string"}
    from: {kind: literal, value: [alpha, beta, gamma]}
nodes:
  - id: count
    description: Print the number of items
    uses: demo.count
    requires: [{name: items, type: "[]string"}]
    provides: []
    timeout: 2s
    retry: {attempts: 3, delay: 10ms}
`

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	items := pulse.NewKey[[]string](https://github.com/Luo-root/pulse/blob/main/yaml/"items")
	reg := pulse.NewRegistry()
	if err := pulse.RegisterKey(reg, items); err != nil {
		return err
	}
	if err := reg.Register("demo.count", func(rc *pulse.RunCtx) error {
		values, err := pulse.Get(rc, items)
		if err != nil {
			return err
		}
		if _, err := fmt.Printf("count=%d\n", len(values)); err != nil {
			return err
		}
		return nil
	}); err != nil {
		return err
	}

	g, plan, err := pulseyaml.Load([]byte(document), reg, pulseyaml.LoadOptions{
		GraphID: "demo",
	})
	if err != nil {
		return err
	}
	in, err := plan.Inputs(nil)
	if err != nil {
		return err
	}
	if err := g.Run(context.Background(), in); err != nil {
		return err
	}
	return nil
}

The YAML decoder produces a generic list, which Inputs converts to the registered []string type. This is literal shape alignment and needs no resolver.

APIs and assembly stages ​

EntryResultUse when
Load(data, reg, opts)(*pulse.Graph, *SeedPlan, error)YAML bytes and a host Registry are available, ready for execution
LoadFile(path, reg, opts)Same as LoadRead a YAML file, then assemble under Load's rules
Analyze(data, opts)(*pulse.Topology, error)Inspect the document and static structure without a host Registry
plan.Inputs(resolve)(pulse.Inputs, error)Obtain this run's inputs before each execution

Load requires a non-nil Registry. GraphID is required by both Load and Analyze. Load creates no Execution, runs no factories or aspects, and resolves no root-level external seeds. On failure it returns nil, nil, err; check the error before using either result. A root seed conflicting with a node's output is rejected in the check tree, before the real tree constructs ObserverFor instances.

StageMain checks
Registry registrationNonempty, unique factory names and non-nil bodies; keys sharing a name must have the same actual Go type
LoadDocument version and node shapes, registered factories and keys, type tags, binds, graph references and reference cycles, child fixed inputs, and static problems at each child mount
SeedPlan.InputsRoot resolver results, literal assignability, nil values and duplicate inputs
Graph.ValidateSources, outlets, dependency cycles and other static problems in the whole definition; success seals the definition tree
Run / NewExecution / StartRequired inlets have supplied or explicitly skipped inputs for this run; Start rechecks static structure and seals the definition

Load does not judge every root-level static problem in advance, such as missing root sources or dependency cycles. The host may still add root input declarations afterward. Call g.Validate() after completing those declarations, or let startup validate them. A child's interface is already determined by its reference site, so Load reports its static problems with the mount path.

Each SeedPlan.Inputs call creates an independent input table, allowing executions of one definition to receive different values. Table isolation is not a deep copy: pointers, maps and other mutable objects inside host values remain the host's responsibility.

LoadOptions ​

FieldPurposeRules
GraphIDRoot definition identityRequired; the engine separately creates an execution identity for each run
GraphRoot []pulse.OptionFor WithObserver, WithAspects and similar options; children inherit the effective execution environment rather than redeclaring them at each layer
MaxRunningShared running-node quota for the declaration treePrefer configuring the quota here; defaults to unlimited, and mount nodes consume no quota themselves
ObserverForfunc(path string) pulse.ObserverSelects separate observers at child mount sites; omit when destinations need not differ

A positive MaxRunning overrides WithMaxRunning in Graph. MaxRunning <= 0 does not override it. An int zero cannot distinguish an unspecified option from an explicit 0; for the default unlimited tree, avoid separately setting WithMaxRunning in Graph.

ObserverFor is called once per child mount during actual graph assembly, with paths such as step or outer/inner. Configure the root observer through Graph, not this callback. Returning nil leaves that site to inherit the parent execution's observer. The build-only validation tree and Analyze do not call ObserverFor or emit observation events. An inherited observer can still distinguish layers from every callback's Site.Path.

LoadOptions has no context field. A graph definition has no lifecycle. Run / NewExecution supplies the execution context, and child execution contexts derive from the mounting node's context.

Registry and Go types ​

Registration interfaces ​

InterfaceContract
pulse.NewRegistry()Creates an independent assembly-time registry
pulse.RegisterKey(reg, key)Registers a Key name and actual Go type; repeating the same name and type is allowed, a different type is rejected
reg.Register(name, run)Registers a named pulse.RunFunc, shaped as func(*pulse.RunCtx) error; duplicate names are rejected
reg.TypeTagOf(name)Returns (string, bool), supplying the type tag to use in the document
reg.ResolveKey(name, typeTag)Reconciles a name and nonempty type tag; unregistered keys or mismatched types are rejected
pulse.MustRegisterKey / reg.MustRegisterPanic versions of registration, for known-valid static initialization

A "factory" here is a named Run body, not a constructor returning Node. Load finds it and builds a node from the YAML requires / provides declarations. The body uses declared keys through pulse.Get / pulse.Set; missing declarations and type mismatches remain subject to core validation.

Type tags ​

Load requires KeySpec.type to exactly match the registered key's reflect.Type.String(). It is not an arbitrary nickname or a Go import alias.

Registered Go typeTypical type tag
stringstring
[]string[]string
map[string]intmap[string]int
Doc defined in the current main packagemain.Doc, or []main.Doc for a list
Doc defined in package modelmodel.Doc, or []model.Doc for a list
anyinterface {}

Named types retain their package and type names. A type alias uses the actual type it denotes, and import aliases do not change reflection tags. When diagnosing a mismatch, use TypeTagOf or the expected tag in the error instead of guessing abbreviations such as []Doc.

YAML field reference ​

These fields match the current Document / NodeSpec / SeedSpec and GraphSpec. Node declaration order does not determine execution order; data dependencies determine when Run can begin.

Load and Analyze accept exactly one YAML document. Appending another with ---, including an empty document, returns an error. Trailing whitespace, comments, and a single document's end marker remain valid. Unknown configuration fields also return an error, including misspelled timeuot or retry.attmpts, so important settings cannot silently disappear. Custom from.kind values and arbitrary business fields inside from.value remain host-defined and are not restricted by the configuration field list.

Numeric values for version and retry.attempts at every node level must be YAML integer scalars within the host Go int range. Fractions, integral floats such as 1.0 or 2e0, numeric strings, and out-of-range values return errors rather than being truncated. Omitted fields and null remain unset; version 0 / 1 and the default for attempts <= 0 keep their existing rules. This restriction does not apply to business from.value data, whose numbers follow the literal conversion rules below.

Document and graph declarations ​

Location / fieldSyntaxMeaning
versionInteger; prefer 1Omitted, 0 or 1 is accepted; other versions are rejected
descriptionStringHuman-readable root description in Topology / Snapshot; no execution semantics
nodesNodeSpec listAt least one root node
seedsSeedSpec listRoot inlet declarations and SeedPlan entries, retaining document order
graphsName → GraphSpecFlat document-wide table of reusable child graphs
graphs.<name>.descriptionStringDescription of each assembled child instance
graphs.<name>.nodesNodeSpec listAt least one node per child graph
graphs.<name>.seedsSeedSpec listFixed inputs at child mounts; only literal or skip
observerStringDocumentation hint only, ignored by Load; configure observers through LoadOptions

GraphSpec has no further graphs table. Nesting references names in the top-level graphs table.

Nodes ​

FieldFactory node (uses)Graph node (graph)
idRequired and unique in its graph; / is allowedRequired and unique in its graph, with no /; forms a mount-path segment
descriptionOptional node descriptionDescription of the parent-side mount node; GraphSpec describes the child itself
usesRequired registered factory nameCannot coexist with graph
graphCannot coexist with usesRequired child graph name
requires[{name, type}, ...]; omitted means emptyNonempty declarations rejected; inputs derive from in
provides[{name, type}, ...]; omitted means emptyNonempty declarations rejected; outputs derive from out
inNonempty declarations rejected{child key: parent key}, supplying parent values to the child
outNonempty declarations rejected{child key: parent key}, bridging child outputs to the parent
timeoutGo duration string, such as 30sLimits the entire child-graph step
retryRetries the entire child-graph step

Every node must choose exactly one of uses and graph. A graph node needs at least one in or out bind. Explicit [] for requires / provides makes an empty input or output set visible.

The current syntax has no top-level output-port field or fix: field. Provides declares the keys a node may write; it does not automatically declare a root host outlet. To read a host result, declare the outlet with Go's DeclareOutput before execution and retain an Execution as shown in the ReadOutput example.

timeout and retry ​

Durations use Go's time.ParseDuration form, such as 30s or 100ms, not bare numbers. Timeout installs an aspect only when greater than 0. A present retry installs Retry; attempts includes the first try, values <= 0 mean one attempt, and delay <= 0 means no wait.

Assembly order is fixed: Timeout outside, Retry inside, regardless of YAML field order. Timeout therefore covers waiting for inputs, all attempts and their delays; it is not a separate budget for each attempt. Timeout is cooperative cancellation: the inner body must observe context. If it ignores cancellation, the framework still waits for it to return and does not forcibly stop its goroutine.

Normal skipping and an already-canceled context do not trigger retries. Retry stages engine outputs for each attempt, withholding failed-attempt outputs from downstream. External effects such as HTTP requests, database writes and files are not automatically rolled back. See the root Retry contract for further rules.

KeySpec, SeedSpec and SeedFrom ​

FieldMeaning
key: {name, type}Required registered key name and type tag
skip: trueExplicitly skips this run's input; from is unused
from.kindLiteral or an empty string takes value directly; other values go to the host resolver
from.valueYAML literal value
from.envVariable name for an env resolver
from.pathPath for a file resolver
from.keyHost key for a context or similar resolver

At the root, env, file and context describe data passed to the resolver; they are not built-in framework IO. Other non-literal kinds are also left to the resolver. Inputs takes func(from pulseyaml.SeedFrom) (any, error): the host decides how to read data and handle absence, and returning an error terminates Inputs. A closure can retain the request context when needed.

Literal and skip entries never call the resolver; nil is sufficient for a plan containing only those entries. A non-literal entry without a resolver fails. LoadFile reads the YAML document itself, not files referenced by seeds.

Literal shape alignment ​

Inputs recursively aligns decoded YAML values to registered types using the same rules as pulse.SeedByName. Child literals are aligned by FixByName during Load.

YAML value → registered typeResult
[a, b] → []stringElement-by-element conversion
{a: 1} → map[string]intKey-by-key and value-by-value conversion
Matching-length list → arrayElement conversion; lengths must match
Numeric value → numeric typeRange checks; float-to-integer requires an integral value and never truncates a fraction
Map → structNo field guessing; fails, so return a correctly typed struct from a resolver
String ↔ numberNo automatic conversion; fails

Numeric conversion to floating point follows Go conversion rules, so integers may lose precision. A nil interface is not a host input value; explicitly skip missing values. Duplicate root inputs fail during Inputs. Duplicate child seeds fail during Load / Analyze.

Analyze: static views without a Registry ​

This analysis helper can be placed in a program importing fmt and this package:

go
func describe(doc []byte) error {
	top, err := pulseyaml.Analyze(doc, pulseyaml.LoadOptions{GraphID: "demo"})
	if err != nil {
		return err
	}
	if len(top.Problems) > 0 {
		return fmt.Errorf("static problems: %v", top.Problems)
	}
	if _, err := fmt.Print(top.Mermaid()); err != nil {
		return err
	}
	return nil
}

Analyze shares decoding, assembly, ports, binds and core static checks that do not depend on real key types with Load, and returns *pulse.Topology. It creates no Execution, runs no factories or aspects, resolves no external seeds and invokes neither ObserverFor nor Observer. Graph options are still applied; their preinstalled nodes, ports and Sub binds also become any placeholders so they can assemble with same-name YAML keys. Labels propagate across non-fixed host Sub binds; host internal keys are scoped by mount position, and a fixed input does not connect a same-name parent key. Host Go types do not participate in document label checks; Load / Validate / Start must still check actual types and runtime configuration.

CheckAnalyzeLoad / Inputs / Validate / Start
YAML shapes, nodes, references and bindsCheckedChecked
Root static problems independent of real key typesRetained in Topology.Problems; the caller must inspect themLoad does not judge every root problem early; Validate / Start checks them
Child and assembly errors within that scopeReturned as errorsReturned as errors
Known type labels on same-name keys and bind endsMust agree and propagate along bindsChecked against actual Registry types
Registered factoriesNot checked; unknown names can passChecked by Load
A label denotes a real Go typeNot provedLoad checks the Registry
A literal is assignable to its target typeNot inferred or checkedRoot Inputs and child Load check it
Stream consumers, shared quotas and lifecycle constraints that depend on real typesNot checked, even with explicit Stream / channel labelsLoad uses real types and checks children; inspect root Topology.Problems and check through Validate / Start
Streaming behavior inside a factoryNot inferredCannot infer it from Run either

Explicit KeySpec declarations still require type. Analyze preserves labels such as example.Item verbatim and does not infer a type from a literal. A key appearing only in wiring, whose type cannot be inferred from any known declaration, has an empty returned Type. Analysis assembles every key using an any placeholder; restoring labels before returning does not rerun checks that need real types. Type labels are not proof of static safety. For example:

yaml
nodes:
  - id: writer
    uses: write
    provides: [{name: stream, type: "pulse.Stream[int]"}]

This document has no reader, yet Analyze returns empty Problems and preserves the stream label. Register a real Key[pulse.Stream[int]] and Load it: the same root graph's Topology.Problems reports the missing reader, and Validate rejects it, without running the factory. Successful Analyze still needs Load with a Registry, Inputs and Validate / Start to check real types, values and stream constraints.

Topology preserves every mount's complete input, output and pass-through interface; internal keys do not automatically become outlets. Go ports follow first successful declaration order. YAML in / out maps are sorted by key, root seeds follow document order, and child fixed inputs are declared after in ports in key-name order. For graphs without streams, known labels and matching identities, wiring and static options allow Analyze, Load and hand-built Go topologies to be compared field by field. Graphs with streams still support assembly previews, but their Problems need not match. Label checks also happen earlier, so documents with multiple errors are not guaranteed the same first diagnostic.

YAML does not declare Go stream-sugar markers such as Produce / Consume / Tee. Static checks cannot infer hidden channel waits or manual close timing inside factories or aspects.

Reusable static subgraphs ​

Graphs declares topology templates; nodes[].graph statically mounts one at a step. This template is mounted twice. The child prefix is fixed, while topic comes from each site's in wiring:

yaml
version: 1
graphs:
  enrich:
    description: Add a prefix
    seeds:
      - key: {name: sg.prefix, type: string}
        from: {kind: literal, value: "Summary: "}
    nodes:
      - id: work
        uses: demo.enrich
        requires:
          - {name: sg.topic, type: string}
          - {name: sg.prefix, type: string}
        provides: [{name: sg.summary, type: string}]
seeds:
  - key: {name: root.input, type: string}
    from: {kind: literal, value: "slot contract"}
nodes:
  - id: first
    graph: enrich
    in: {sg.topic: root.input}
    out: {sg.summary: root.first}
  - id: second
    graph: enrich
    in: {sg.topic: root.input}
    out: {sg.summary: root.second}

To run this fragment, register six string keys: root.input, root.first, root.second, sg.topic, sg.prefix and sg.summary, plus the demo.enrich body. The body reads sg.topic and sg.prefix and writes sg.summary.

Each mount creates an independent definition during Load and expands into the engine's static Sub. In / out derives the parent mount node's Requires / Provides. The complete subtree is visible through g.Topology().Nodes[i].Sub without running. Child IDs are <root id>/<mount path>; paths are first, second or outer/inner.

At run time, every mount and every retry attempt creates an independent child Execution with its own slots, first error and cancellation domain. The child context derives from the mount node. Parent cancellation propagates; bodies must still respond cooperatively.

Wiring and fixed inputs ​

DeclarationDirection and rules
in: {child: parent}Parent read → child input; child becomes an inlet at this reference site
out: {child: parent}Child outlet → parent output; child becomes an outlet at this reference site
Child seeds literalBecomes a Fix bind at the mount; fixed values do not enter the definition
Child seeds skipBecomes a FixSkip bind at the mount; from is ignored
The same child key in both in and outPass-through: the parent value is directly the child outlet value, with no internal reader or producer required

Both keys must be registered and their actual types must match. A non-pass-through in key must be required by the child; a non-pass-through out key must be provided by it. An in key cannot also be produced inside the child or declared in that child's seeds, since either would give the input two sources. Pass-through still forbids an internal producer.

Non-skip child seeds support only literal or an omitted kind. Env / file / context and other kinds are rejected during assembly. Resolve external values through the root SeedPlan and pass them with in. Duplicate child seeds, unknown references, empty nodes, invalid binds and reference cycles are reported during assembly. A reference cycle includes a concrete path. Nesting itself has no fixed depth limit.

Validation per reference site ​

Each reference site's in / out / seeds independently determines the spec's interface. If one of two mounts wires an input and the other does not, the second still lacks a source; the diagnostic points to that mount path. Validation does not merge the sites' inputs to excuse the missing bind.

Each child's sources, declared outlets and dependencies use the core Validate / Start criteria. An out-declared child outlet with no producer is therefore rejected during Load, without waiting until execution to discover that it never arrived.

Every graphs declaration is checked. A spec with no references has no mount interface: an unresolved Requires could become a future in inlet, so missing sources are not judged there. Known literal / skip seeds are still real external sources; a node providing the same key makes both Load and Analyze reject it with ErrDuplicateSource. Empty nodes, duplicate IDs, known source conflicts, reference cycles and dependency cycles remain checked. Load also checks its registered factories and actual types. Concrete nested references inside templates are still checked against their wiring.

An unreferenced template's own seeds retain their fixed-input facts too: Load translates them into the same Fix / FixSkip binds as a real mount on a build-only temporary host. Unknown inlets use ordinary In binds, remain unknown, and do not require future run inputs. Without effective aspects, the core propagates definite skips through ordinary-value relays and nested binds, avoiding missing-reader diagnostics for streams that cannot be produced. Literal-ready or unknown inputs can still produce streams; effective aspects can also write outputs, so skipping the body alone does not exempt reader checks. Analyze still does not prove these real stream constraints from type labels.

An unreferenced template borrows only the validation root's applied default declarations (Defaults). It never replays Graph Options or imports host-preinstalled nodes, ports, or Sub wiring; the temporary host does not declare those defaults a second time. Template count does not increase root Option calls, and root and template nodes may have the same ID in their separate definitions. Load retains quota and graph-aspect facts for static stream checks. Template checks read Topology.Problems without creating an Execution or calling the sealing Validate operation. The temporary host appears in neither the returned root graph nor SeedPlan; neither success nor a later template failure seals a shared host child through this path. The loader does not roll back arbitrary external effects of root Options themselves.

Subgraph timeout and retry ​

Timeout / retry on a graph node applies to the parent mount node, controlling the entire child-graph step. Configuration on a factory node inside the child controls only that internal node.

Each whole-child retry attempt receives a new child Execution. Failed-attempt outputs bridged toward the parent are discarded with that attempt's write set. The parent receives outputs committed by a successful or normally skipped attempt, without mixing versions from different attempts. Multi-key commits become visible key by key, not atomically as a whole batch at one instant. External side effects are not automatically rolled back.

Common questions ​

QuestionAction
Analyze succeeds, but Load reports an unknown factory?Analyze does not use the host Registry; register the RunFunc named by uses
Why does []Doc mismatch the registry?Obtain the reflection tag from TypeTagOf; named types typically include a package name
Load succeeds, but Run reports a missing source or input?Load allows later root assembly and per-run input supply; check inlet declarations, Inputs and root Topology.Problems
Why does YAML observer have no effect?It is a hint; configure the root observer with LoadOptions.Graph and separate child sinks with ObserverFor
How can a child read an external file?Resolve a correctly typed root value, then pass it with in
How do I declare a host output?Use Go DeclareOutput and read through Execution + ReadOutput; provides is not a host port
Does skip fail the whole run?Normal skip is an arrival without a failure error; real errors retain their failure identity

Development API documentation · Go 1.26+ · MIT