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:
- Parse + env substitution. The document parses into a
yaml.Node; every string scalar goes through${VAR}/${?VAR}substitution (unset${VAR}is acfg_env_unseterror; unset${?VAR}omits the key; substituted values are never re-scanned — single-pass, ruling D1). - Structural decode into generic maps.
- Constants substitution.
${constants.x}expands (unknown constant = error);${parameters.x}passes through untouched only in job pipelines (run.mode: job) — anywhere else it is acfg_scope_unknownerror. The optional marker?is only legal for plain environment variables. - Structural validation with whitelists: top-level keys,
metadata.namevalidation (metadata is a strictly checked mapping: unknown keys and a non-mapping value are errors), thelimits/run/parameters/hooks/telemetry/codecs/dlqsections, 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 packageinternal/schema(candidate 06) — config, registry and the LSP read one vocabulary, so a name likegrpcorversioncannot 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) —
| Section | Allowed framework fields |
|---|---|
sources | decoder (default materialized to json), grpc, version (never depends_on) |
transforms | depends_on, workers, version |
sinks | depends_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).
| Key | Default | Meaning |
|---|---|---|
storage.data_dir | data | SQLite storage directory; each pipeline’s store is <dir>/stores/<sanitized name>.db (candidate 04) |
storage.ephemeral | false | in-memory store, nothing persists |
storage.spool_retention | 10000 | spool rows kept behind the checkpoint |
admin.listen | 127.0.0.1:7788 | admin listener bind address |
admin.enable | true | serve the admin surface (config-dir daemon mode) |
admin.token | "" | bearer token (mandatory for non-loopback binds) |
mcp.enable | true | register /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_ratio | 0.1 | trace sampler ratio |
telemetry.prometheus | true | serve 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:
- Plugin lookup — the plugin key must resolve in the registry
(
plugin_unknown) or, for external nodes, match its manifest (grpc_manifest_name,grpc_builtin_conflictfor compiled-in names). - 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. - JSON Schema validation — the plugin block validates against the
generated/manifest schema (
plugin_schema). - 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
| Code | Sev | Fires when | Example |
|---|---|---|---|
io_read | error | the config file cannot be read | open p.yaml: The system cannot find the file specified. |
yaml_parse | error | YAML syntax error, top level not a mapping, or decode failure | top level must be a mapping |
empty_config | error | the document is empty | configuration is empty |
cfg_unknown_top_section | error | unknown top-level key | unknown top-level key "codecs2" |
cfg_api_version | error | apiVersion is not eventboat/v1 | apiVersion must be "eventboat/v1" |
cfg_kind | error | kind is not Pipeline | kind must be "Pipeline" |
cfg_metadata_name | error | metadata.name missing/blank | metadata.name is required |
cfg_metadata_type | error | metadata is not a mapping | metadata must be a mapping |
cfg_name_invalid | error | name violates charset/../reserved-name rules | metadata.name "con" must be 1-64 characters of [a-zA-Z0-9._-]... |
cfg_env_unset | error | ${VAR} references an unset variable | environment variable TOKEN is not set |
cfg_constant_unknown | error | ${constants.x} names an undeclared constant | unknown constant "vip" |
cfg_scope_unknown | error | unknown scope, ${parameters.*} outside a job pipeline, or ? on a scoped reference | unknown scoped reference ${parameters.x}: parameters are only available in job pipelines |
Loader: sections
| Code | Sev | Fires when |
|---|---|---|
cfg_limits_type | error | limits not a mapping; drain_timeout not a duration string |
cfg_limits_range | error | max_in_flight < 1; drain_timeout not positive |
cfg_run_type | error | run not a mapping |
cfg_run_mode | error | run.mode not continuous/job/batch |
cfg_run_schedule | error | empty schedule, or schedule without mode: job |
cfg_run_overlap | error | run.overlap not `skip |
cfg_run_catchup | error | catchup_window not a valid duration |
cfg_run_skip | error | skip_if_successful not a boolean |
cfg_run_retention | error | retention shape/type/range errors |
cfg_dlq_type | error | dlq not a mapping |
cfg_dlq_retention | error | dlq.retention missing/not a duration/not positive |
cfg_telemetry_type | error | telemetry not a mapping |
cfg_telemetry_redact | error | redact not a list of non-empty strings |
cfg_telemetry_span_rate | error | span_sample_rate not a number in [0,1] |
cfg_codecs_type | error | codecs or a declaration not a mapping |
cfg_codec_type | error | codec declaration missing type |
cfg_parameters_type | error | parameters/a declaration not a mapping |
cfg_parameters_not_job | error | parameters: present without run.mode: job |
cfg_parameters_decl | error | any parameter-declaration rule: type mismatch, bad enum/pattern/min/max, default violating constraints, required + default |
cfg_hooks_type | error | hooks not a mapping |
cfg_hooks_sink | error | unknown hook name, or hook not exactly one inline sink block |
cfg_unknown_field | error | unknown field inside metadata, limits, run, retention, dlq, parameters, grpc, batch, edge attribute blocks, when objects, buffer, delivery |
Loader/sections: nodes and edges
| Code | Sev | Fires when |
|---|---|---|
cfg_missing_section | error | sources: or sinks: absent |
cfg_section_type | error | a section is not a mapping |
cfg_empty_section | error | a section present but empty |
cfg_node_type | error | a node is not a mapping |
cfg_source_with_depends_on | error | a source declares depends_on (sources have no in-edges) |
cfg_sink_workers | error | a sink declares workers (not implemented; sink concurrency is engine-owned) |
cfg_from_renamed | error | node still uses the old from key, renamed to depends_on (migration diagnostic) |
cfg_missing_plugin | error | node has no plugin block |
cfg_multiple_plugins | error | node has more than one plugin block |
cfg_plugin_block_type | error | source/sink plugin block is not a mapping |
cfg_missing_depends_on | error | transform/sink without depends_on |
cfg_bad_depends_on | error | depends_on not a name/list/single-key mapping; empty name; multi-key element; non-mapping attrs |
cfg_when_type | error | when empty, not a string/object, or expr empty |
cfg_when_lang | error | when.lang not cel/cesql |
cfg_when_route_exclusive | error | when and route on one edge |
cfg_edge_defaults_field | error | edge_defaults is not a mapping, or declares when/route (only delivery/required/buffer are allowed) |
cfg_route_type | error | route not a non-empty name |
cfg_required_type | error | required not a boolean |
cfg_buffer_type | error | buffer not a mapping; type not memory |
cfg_buffer_range | error | buffer.max_events < 1 |
cfg_delivery_type | error | delivery not a mapping |
cfg_delivery_range | error | retries < 0; timeout_ms < 1 |
cfg_delivery_backoff | error | backoff not exponential/constant |
cfg_decoder_type | error | source decoder not a codec name string |
cfg_encoder_type | error | sink encoder not a codec name string |
cfg_order_key_type | error | sink order_key not a CEL string |
cfg_batch_type | error | batch not a mapping; unknown batch fields |
cfg_batch_range | error | batch.size < 1; timeout_ms < 1 |
cfg_workers_range | error | workers < 1 |
cfg_version_range | error | node version: < 1 |
cfg_grpc_type | error | grpc not a mapping |
cfg_grpc_command | error | grpc.command not a non-empty string argv |
cfg_grpc_env | error | grpc.env value not a string |
cfg_grpc_schema | error | grpc.schema not a manifest path |
cfg_grpc_restart | error | grpc.restart not fast-fail/restart |
topo_dup_name | error | duplicate node name within a section (loader) or across all three sections (ir) |
grpc_manifest_read | error | manifest file unreadable, or path references job parameters |
grpc_manifest_parse | error | manifest is not valid JSON |
grpc_manifest_kind | error | manifest kind mismatches the node’s section |
grpc_manifest_version | error | manifest version < 1 |
grpc_manifest_schema | error | manifest has no config_schema (loader) or schema marshal fails (ir) |
IR: topology, plugins, expressions, jobs, lint
| Code | Sev | Fires when |
|---|---|---|
topo_missing_ref | error | depends_on references an unknown node |
topo_sink_as_upstream | error | depends_on references a sink (sinks have no out-edges) |
topo_cycle | error | the DAG has a cycle |
topo_no_path | error | no source-to-sink path exists |
topo_orphan | error | source with no downstream; non-source with no in-edges |
plugin_unknown | error | plugin key not in the registry (all kinds; transform hint notes gRPC transforms are future work) |
plugin_version_mismatch | error | node version: ≠ registered/manifest version |
plugin_schema | error | plugin block fails its JSON Schema (also hook sink validation) |
grpc_builtin_conflict | error | grpc: block on a compiled-in plugin name |
grpc_manifest_name | error | manifest name ≠ plugin block key |
codec_unknown | error | decoder/encoder names an unknown codec |
codec_config | error | a codec needing configuration is referenced bare instead of via codecs: |
cfg_codec_shadow | error | codecs: declaration name shadows a registered codec |
expr_cel_env | error | the CEL environment cannot be built |
expr_cel_compile | error | a CEL predicate or order key fails to compile |
expr_route_compile | error | a route:-sugared edge’s compiled CEL fails |
expr_route_dangling | error | route: edge whose upstream transform never assigns meta.route |
expr_cesql_compile | error | a CESQL when fails to parse |
expr_starlark_compile | error | script plugin: Starlark resolve failed (via TransformError.DiagCode, with backtrace context) |
expr_wasm_compile | error | wasm plugin: module unreadable, not a reactor, or missing ABI exports (via TransformError.DiagCode) |
wasm_no_kill_switch | warning | wasm transform without timeout_ms: no kill switch (escalated by --strict) |
run_retention_unset | warning | job pipeline without run.retention.history: run history keeps forever (escalated by --strict) |
batch_no_finite_source | warning | run.mode: batch but no source declares finite exhaustion: the run hangs until cancelled (escalated by --strict) |
job_file_source_no_eof | warning | job pipeline file source without on_eof: stop: the run never completes (escalated by --strict) |
job_bad_schedule | error | run.schedule is not a valid 5-field cron |
job_source_not_pull | error | job pipeline source lacks the pull capability |
job_multiple_pull_sources | warning | job pipeline with >1 pull source (cursor binds the first) |
job_parameters_in_continuous | error | parameters.* referenced in a continuous pipeline |
job_parameter_unknown | error | ${parameters.x} names an undeclared parameter (verify; also the runtime backstop in SubstituteParameters) |
telemetry_redact_pattern | error | a telemetry.redact pattern is not a valid field-path glob |
lint_when_literal | warning | edge condition is literally true/false |
lint_constant_unused | warning | a declared constant is never referenced |
lint_sql_continuous | warning | a pull source (sql / pull-capable plugin) used in a continuous pipeline |
lint_line_bytes_over_vl | warning | file 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_timeout | warning | file 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_one | warning | file 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/testCLI verbs printseverity[code] file:line: messageplus hints;--jsonemits the structs.- The LSP converts the same diagnostics into editor squiggles
(
internal/lsp/diagnostics.go). - The MCP
verifytool returns them with anokverdict so agents iterate;deployfails the tool call when verification fails — verify-first has no bypass.