Skip to content

Configuration & diagnostics

A pipeline is one YAML file loaded by internal/config and compiled by internal/ir. Loading is deliberately strict: unknown keys, unknown plugins and un-compilable expressions are errors, not warnings — the config is a contract with agents as much as with humans. Every finding surfaces as a config.Diagnostic with a stable code, severity, file/line, message and hint (internal/config/config.go); --json output, the LSP, the MCP verify tool and the admin UI all render the same structs.

The load pipeline

LoadBytes(file, data) (internal/config/loader.go) runs four passes:

  1. Parse + env substitution. The document parses into a yaml.Node; every string scalar goes through ${VAR} / ${?VAR} substitution (unset ${VAR} is a cfg_env_unset error; unset ${?VAR} omits the key; substituted values are never re-scanned — single-pass, ruling D1).
  2. Structural decode into generic maps.
  3. Constants substitution. ${constants.x} expands (unknown constant = error); ${parameters.x} passes through untouched only in job pipelines (run.mode: job) — anywhere else it is a cfg_scope_unknown error. The optional marker ? is only legal for plain environment variables.
  4. Structural validation with whitelists: top-level keys, metadata.name validation (metadata is a strictly checked mapping: unknown keys and a non-mapping value are errors), the limits/run/parameters/hooks/telemetry/codecs/dlq sections, the three node sections with their framework-field whitelists, and finally manifest reads for every external (grpc:) node. The whitelists, the top-level keys, the edge attributes, the reserved plugin names and the node-level default constants all come from the single leaf package internal/schema (candidate 06) — config, registry and the LSP read one vocabulary, so a name like grpc or version cannot register as a plugin that config would parse as a framework field.

metadata.name is a conservative identifier (cfg_name_invalid): 1–64 characters of [a-zA-Z0-9._-], starting alphanumeric, no .. (path traversal), and no Windows reserved device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9) — the name becomes the deployed file name and the store key.

Pipeline.Order is the YAML document order of node declarations (candidate 06), preserved from the node stream: jobs binds cursor to the first source in that order, explain picks its default entry node from it and the diagnostics iterate it, so a shuffled document still behaves like the one a human reads.

The three-section topology

sources, transforms, sinks — each node is name: {plugin block, ...framework fields}. depends_on edges join them. Sources and sinks are required; the transforms section is optional. Exact node-level whitelists (internal/schema, read by internal/config/sections.go) —

SectionAllowed framework fields
sourcesdecoder (default materialized to json), grpc, version (never depends_on)
transformsdepends_on, workers, version
sinksdepends_on, encoder (default json), order_key, batch, grpc, version

— everything else at node level must be exactly one plugin key. workers is transform-only: sinks.workers was accepted and ignored before and is now rejected with cfg_sink_workers (sink concurrency is engine-owned; implement or refuse). Edge attributes on a depends_on element: when, route, buffer, delivery, required. when accepts a string (CEL) or {lang: cel|cesql, expr: "..."}; route is sugar compiled to meta.route == "<name>" and is mutually exclusive with when. edge_defaults accepts only delivery, required and buffer — a global default predicate is a footgun and a default route is meaningless, so when/route there are cfg_edge_defaults_field errors (candidate 06). Pipeline-level defaults (decoder/encoder json, delivery retries: 3 / backoff: exponential, required: true, buffer max_events: 128, workers 1) are materialized into the typed config at load: downstream layers read the typed field instead of re-applying a default (the json fallback used to be copied in ten places).

Runtime configuration

Deployment-level settings live in a separate kind: Runtime document (internal/runtimecfg/runtimecfg.go), resolved from --runtime, then ./eventboat.yaml, then defaults; CLI flags override. The decode is typed and strict (candidate 06): unknown keys AND type errors are errors (data_dir: 123, enable: "yes", sample_ratio: -1 used to fall through to the defaults), and sample_ratio must be in [0, 1] (0 = tracing off).

KeyDefaultMeaning
storage.data_dirdataSQLite storage directory; each pipeline’s store is <dir>/stores/<sanitized name>.db (candidate 04)
storage.ephemeralfalsein-memory store, nothing persists
storage.spool_retention10000spool rows kept behind the checkpoint
admin.listen127.0.0.1:7788admin listener bind address
admin.enabletrueserve the admin surface (config-dir daemon mode)
admin.token""bearer token (mandatory for non-loopback binds)
mcp.enabletrueregister /mcp next to the admin surface; false means the daemon does not serve MCP at all (the explicit eventboat mcp --http command always does)
telemetry.otlp_endpoint""OTLP/HTTP push endpoint (empty = off)
telemetry.sample_ratio0.1trace sampler ratio
telemetry.prometheustrueserve Prometheus exposition at /metrics

Verify and IR resolution

Production code builds pipelines only through internal/verify (candidate 05): verify.File/verify.Bytes load, build and judge in one call and return a Result with the built *ir.Pipeline, the merged diagnostics and the strict verdict; verify.LoadBytes + verify.Build is the two-stage form for consumers with a middle step (jobs substitutes parameters between the stages). ir.Build(cfg, reg, starOpts, parameters) is the build stage it wraps. Per node, in order:

  1. Plugin lookup — the plugin key must resolve in the registry (plugin_unknown) or, for external nodes, match its manifest (grpc_manifest_name, grpc_builtin_conflict for compiled-in names).
  2. Version pin check — a node version: must equal the registered or manifest version (plugin_version_mismatch); this is the §6.5 rule that makes “documented but absent” impossible.
  3. JSON Schema validation — the plugin block validates against the generated/manifest schema (plugin_schema).
  4. Factory instantiation — the registry factory builds a real instance at verify time: the script plugin compiles the Starlark program (expr_starlark_compile), the wasm plugin compiles the module and checks ABI exports (expr_wasm_compile), sources/sinks run their typed build functions (cross-field rules JSON Schema cannot express live here).

Transform instances declaring explain-safe are retained on the IR for explain dry-runs — but only in the retaining build (verify.Options.ForExplain, used by the CLI/ops explain entries and replay --dry-run); the default verify-only build validates every instance and closes it immediately, failure paths included, so nothing outlives a verify. An explain caller owns the pipeline and closes the retained instances with ir.Pipeline.Close when the walkthrough is done. Others (wasm — explain never executes guest code) are always closed immediately. Then: codec resolution (codec_unknown, codec_config), order-key compilation, topological sort, job semantics, telemetry pattern checks and lint.

explain renders the resolved semantics (candidate 09), never a per-plugin re-derivation: the sample message is decoded with the entry source’s declared decoder (the same codec instance the engine resolves — a raw/csv source is not fed JSON), each edge predicate is evaluated once per walk, every inbound edge of a sink is rendered with its own delivery policy plus the engine’s mixed-batch rule (max retries, max explicit timeout; the default timeout applies only when no edge in the batch sets one), and a required: false edge shows its terminal drop — the engine drops and commits the message instead of dead-lettering it. A wasm symbolic line shows the resolved per-invoke mode (fast mode (no per-invoke kill switch) when timeout_ms is unset).

The diagnostics table

Every diagnostic code that exists in the code, by emitting layer. Severity error fails verify; warning is lint (upgraded to error by verify --strict). Example messages are abbreviated.

Loader: file, syntax, document shape

CodeSevFires whenExample
io_readerrorthe config file cannot be readopen p.yaml: The system cannot find the file specified.
yaml_parseerrorYAML syntax error, top level not a mapping, or decode failuretop level must be a mapping
empty_configerrorthe document is emptyconfiguration is empty
cfg_unknown_top_sectionerrorunknown top-level keyunknown top-level key "codecs2"
cfg_api_versionerrorapiVersion is not eventboat/v1apiVersion must be "eventboat/v1"
cfg_kinderrorkind is not Pipelinekind must be "Pipeline"
cfg_metadata_nameerrormetadata.name missing/blankmetadata.name is required
cfg_metadata_typeerrormetadata is not a mappingmetadata must be a mapping
cfg_name_invaliderrorname violates charset/../reserved-name rulesmetadata.name "con" must be 1-64 characters of [a-zA-Z0-9._-]...
cfg_env_unseterror${VAR} references an unset variableenvironment variable TOKEN is not set
cfg_constant_unknownerror${constants.x} names an undeclared constantunknown constant "vip"
cfg_scope_unknownerrorunknown scope, ${parameters.*} outside a job pipeline, or ? on a scoped referenceunknown scoped reference ${parameters.x}: parameters are only available in job pipelines

Loader: sections

CodeSevFires when
cfg_limits_typeerrorlimits not a mapping; drain_timeout not a duration string
cfg_limits_rangeerrormax_in_flight < 1; drain_timeout not positive
cfg_run_typeerrorrun not a mapping
cfg_run_modeerrorrun.mode not continuous/job/batch
cfg_run_scheduleerrorempty schedule, or schedule without mode: job
cfg_run_overlaperrorrun.overlap not `skip
cfg_run_catchuperrorcatchup_window not a valid duration
cfg_run_skiperrorskip_if_successful not a boolean
cfg_run_retentionerrorretention shape/type/range errors
cfg_dlq_typeerrordlq not a mapping
cfg_dlq_retentionerrordlq.retention missing/not a duration/not positive
cfg_telemetry_typeerrortelemetry not a mapping
cfg_telemetry_redacterrorredact not a list of non-empty strings
cfg_telemetry_span_rateerrorspan_sample_rate not a number in [0,1]
cfg_codecs_typeerrorcodecs or a declaration not a mapping
cfg_codec_typeerrorcodec declaration missing type
cfg_parameters_typeerrorparameters/a declaration not a mapping
cfg_parameters_not_joberrorparameters: present without run.mode: job
cfg_parameters_declerrorany parameter-declaration rule: type mismatch, bad enum/pattern/min/max, default violating constraints, required + default
cfg_hooks_typeerrorhooks not a mapping
cfg_hooks_sinkerrorunknown hook name, or hook not exactly one inline sink block
cfg_unknown_fielderrorunknown field inside metadata, limits, run, retention, dlq, parameters, grpc, batch, edge attribute blocks, when objects, buffer, delivery

Loader/sections: nodes and edges

CodeSevFires when
cfg_missing_sectionerrorsources: or sinks: absent
cfg_section_typeerrora section is not a mapping
cfg_empty_sectionerrora section present but empty
cfg_node_typeerrora node is not a mapping
cfg_source_with_depends_onerrora source declares depends_on (sources have no in-edges)
cfg_sink_workerserrora sink declares workers (not implemented; sink concurrency is engine-owned)
cfg_from_renamederrornode still uses the old from key, renamed to depends_on (migration diagnostic)
cfg_missing_pluginerrornode has no plugin block
cfg_multiple_pluginserrornode has more than one plugin block
cfg_plugin_block_typeerrorsource/sink plugin block is not a mapping
cfg_missing_depends_onerrortransform/sink without depends_on
cfg_bad_depends_onerrordepends_on not a name/list/single-key mapping; empty name; multi-key element; non-mapping attrs
cfg_when_typeerrorwhen empty, not a string/object, or expr empty
cfg_when_langerrorwhen.lang not cel/cesql
cfg_when_route_exclusiveerrorwhen and route on one edge
cfg_edge_defaults_fielderroredge_defaults is not a mapping, or declares when/route (only delivery/required/buffer are allowed)
cfg_route_typeerrorroute not a non-empty name
cfg_required_typeerrorrequired not a boolean
cfg_buffer_typeerrorbuffer not a mapping; type not memory
cfg_buffer_rangeerrorbuffer.max_events < 1
cfg_delivery_typeerrordelivery not a mapping
cfg_delivery_rangeerrorretries < 0; timeout_ms < 1
cfg_delivery_backofferrorbackoff not exponential/constant
cfg_decoder_typeerrorsource decoder not a codec name string
cfg_encoder_typeerrorsink encoder not a codec name string
cfg_order_key_typeerrorsink order_key not a CEL string
cfg_batch_typeerrorbatch not a mapping; unknown batch fields
cfg_batch_rangeerrorbatch.size < 1; timeout_ms < 1
cfg_workers_rangeerrorworkers < 1
cfg_version_rangeerrornode version: < 1
cfg_grpc_typeerrorgrpc not a mapping
cfg_grpc_commanderrorgrpc.command not a non-empty string argv
cfg_grpc_enverrorgrpc.env value not a string
cfg_grpc_schemaerrorgrpc.schema not a manifest path
cfg_grpc_restarterrorgrpc.restart not fast-fail/restart
topo_dup_nameerrorduplicate node name within a section (loader) or across all three sections (ir)
grpc_manifest_readerrormanifest file unreadable, or path references job parameters
grpc_manifest_parseerrormanifest is not valid JSON
grpc_manifest_kinderrormanifest kind mismatches the node’s section
grpc_manifest_versionerrormanifest version < 1
grpc_manifest_schemaerrormanifest has no config_schema (loader) or schema marshal fails (ir)

IR: topology, plugins, expressions, jobs, lint

CodeSevFires when
topo_missing_referrordepends_on references an unknown node
topo_sink_as_upstreamerrordepends_on references a sink (sinks have no out-edges)
topo_cycleerrorthe DAG has a cycle
topo_no_patherrorno source-to-sink path exists
topo_orphanerrorsource with no downstream; non-source with no in-edges
plugin_unknownerrorplugin key not in the registry (all kinds; transform hint notes gRPC transforms are future work)
plugin_version_mismatcherrornode version: ≠ registered/manifest version
plugin_schemaerrorplugin block fails its JSON Schema (also hook sink validation)
grpc_builtin_conflicterrorgrpc: block on a compiled-in plugin name
grpc_manifest_nameerrormanifest name ≠ plugin block key
codec_unknownerrordecoder/encoder names an unknown codec
codec_configerrora codec needing configuration is referenced bare instead of via codecs:
cfg_codec_shadowerrorcodecs: declaration name shadows a registered codec
expr_cel_enverrorthe CEL environment cannot be built
expr_cel_compileerrora CEL predicate or order key fails to compile
expr_route_compileerrora route:-sugared edge’s compiled CEL fails
expr_route_danglingerrorroute: edge whose upstream transform never assigns meta.route
expr_cesql_compileerrora CESQL when fails to parse
expr_starlark_compileerrorscript plugin: Starlark resolve failed (via TransformError.DiagCode, with backtrace context)
expr_wasm_compileerrorwasm plugin: module unreadable, not a reactor, or missing ABI exports (via TransformError.DiagCode)
wasm_no_kill_switchwarningwasm transform without timeout_ms: no kill switch (escalated by --strict)
run_retention_unsetwarningjob pipeline without run.retention.history: run history keeps forever (escalated by --strict)
batch_no_finite_sourcewarningrun.mode: batch but no source declares finite exhaustion: the run hangs until cancelled (escalated by --strict)
job_file_source_no_eofwarningjob pipeline file source without on_eof: stop: the run never completes (escalated by --strict)
job_bad_scheduleerrorrun.schedule is not a valid 5-field cron
job_source_not_pullerrorjob pipeline source lacks the pull capability
job_multiple_pull_sourceswarningjob pipeline with >1 pull source (cursor binds the first)
job_parameters_in_continuouserrorparameters.* referenced in a continuous pipeline
job_parameter_unknownerror${parameters.x} names an undeclared parameter (verify; also the runtime backstop in SubstituteParameters)
telemetry_redact_patternerrora telemetry.redact pattern is not a valid field-path glob
lint_when_literalwarningedge condition is literally true/false
lint_constant_unusedwarninga declared constant is never referenced
lint_sql_continuouswarninga pull source (sql / pull-capable plugin) used in a continuous pipeline
lint_line_bytes_over_vlwarningfile source with max_line_bytes > 262144 in a pipeline with a victorialogs sink: VL’s -insert.maxLineSizeBytes default would skip longer lines server-side
lint_multiline_no_timeoutwarningfile source with multiline timeout_ms explicitly 0: no time-based flush, so an isolated trailing group waits for the next group-starting line
lint_collector_batch_onewarningfile source plus a non-drop/debug sink whose effective batch.size is 1 (unset, or explicit 1): the collection shape should batch

Retired codes

Spec v1.19 retired the hand-parsed transform diagnostics — cfg_transform_main_field, cfg_script_type, cfg_split_type, cfg_wasm_type, cfg_wasm_module, cfg_wasm_range, cfg_wasm_allow — their checks now surface as cfg_missing_plugin/cfg_multiple_plugins or plugin_schema issues like every other kind. expr_starlark_compile, expr_wasm_compile and wasm_no_kill_switch survive via error classification (see Plugins).

Where diagnostics are consumed

  • verify / run / test CLI verbs print severity[code] file:line: message plus hints; --json emits the structs.
  • The LSP converts the same diagnostics into editor squiggles (internal/lsp/diagnostics.go).
  • The MCP verify tool returns them with an ok verdict so agents iterate; deploy fails the tool call when verification fails — verify-first has no bypass.