Skip to main content

Spec format

The spec is a tree of TOML files. It is what users, stratactl and the assistant edit, what change sets diff, and what a revision snapshots. The database stores the same tree content-addressed (spec_revision) and is its only source of truth; history, diff, revert and restore are built in.

1. Layout​

spec/
layers/
base.toml # precedence 100, selector {} (everything)
location-dmz.toml
role-tomcat.toml
nodes/web01.example.com.toml
bundles/
procstat-tomcat.toml
routes/
metrics-prod.toml
logs-kafka.toml
policies/
approval/prod-four-eyes.toml
rollout/default.toml
secrets/
snmp_v3_auth.toml # metadata and grant only, never values
placements/
netprobe.toml # pick N online hosts and label them
journeys/ # synthetic journeys (journeys.md)
shop-checkout.toml
settings/
telegraf.toml # watch interval, heartbeat block
elastic-agent.toml # checkin poll timeout
vector.toml # poll interval, heartbeat pipeline
journeys.toml # metric labels, evidence retention
retention.toml # how long state and logs are kept
assistant.toml # default model, prompt addendum
  • One object per file. The path is free within its kind directory; the object's identity is kind + metadata.name, unique per revision.
  • Names are lowercase DNS-subdomain style, ^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$, so a node layer can be named after its FQDN; Secret names are ^[A-Za-z0-9_]+$ instead.
  • Every object has apiVersion = "v1alpha1", kind, and a [metadata] table with name, owner (a team slug) and optional description and labels.
  • Files are canonicalised on write, so diffs stay minimal and a revision hash is stable: apiVersion and kind first, then [metadata], then [spec]; keys in schema order; nested maps as standard tables, lists of maps as arrays of tables; inline tables only where the schema marks a value as compact (selectors, secretRef, small option maps); strings in basic quotes, multi-line strings as literal strings ('''). Comments are kept.

1.1 Parsing limits​

Spec files are TOML 1.0; no YAML parser runs in the server. L0 enforces, before anything else reads a file:

LimitValue
File size1 MiB
Nesting depth32 tables/arrays
Keys per file10,000
String length256 KiB (journey scripts are the largest values)
Duplicate keys, redefined tableserror (as TOML 1.0 requires)

The same limits apply to the JSON bodies the server parses (preview requests, imports, agent reports), with the exceptions listed in the API conventions.

2. Selectors​

Selectors match host labels. They are structured, Kubernetes-compatible, plus a glob operator:

[spec.selector]
matchLabels = { os = "linux" } # all must be equal
matchExpressions = [ # all must hold
{ key = "stage", operator = "In", values = ["prod", "preprod"] },
{ key = "zone", operator = "Glob", values = ["dmz-*"] },
{ key = "decommissioned", operator = "DoesNotExist" },
]
OperatorKey absentKey present with value v
In / NotInfalse / truev in / not in the values
Exists / DoesNotExistfalse / truetrue / false
Glob / NotGlobfalse / truev matches any / no pattern
Gt, Ltfalsev is an integer and v > / < the value; otherwise false
  • selector = {} matches every host. matchLabels { k = v } is In with one value.
  • In, NotIn, Glob and NotGlob need at least one value; Gt and Lt exactly one integer. Integers are -?[0-9]+, compared without a size limit; a label value that is not an integer never matches Gt or Lt.
  • Globs: * matches any sequence including /, ? exactly one character, [a-z] a code-point range, [!…] the complement; ] first and - first or last are literal, \ escapes. Matching is anchored and case-sensitive. A trailing \, an unterminated [ or a reversed range is a validation error.
  • A short form is accepted by the CLI and the API query parameter ?selector=, terms separated by commas: k, !k, k=v, k!=v, k in (a,b), k notin (a,b), k~=glob, k!~=glob, k>n, k<n. For example os=linux,stage in (prod,preprod),zone~=dmz-*,!decommissioned. It is converted to the structured form before it is stored.
  • Selectors translate to SQL on the host.labels JSONB column, so impact and inventory queries run in the database.

Built-in labels: host.name, host.kind (server, client, pod), os, arch, owner (the owner team's slug; absent without an owner team). They are derived: os and arch from agent facts, the others from the host. No other label source (zone, enrollment token, manual) can set them, so labels cannot move a host into another team's scope. Placements (§3.9) set labels of their own source.

Selectors match hosts, everywhere they appear. Which agents of a matching host a layer applies to is set by the layer's agentKind and agentVersions (§3.1), never by labels.

3. Object kinds​

3.1 Layer​

apiVersion = "v1alpha1"
kind = "Layer"

[metadata]
name = "role-tomcat"
owner = "team-middleware"
description = "Tomcat process and JMX monitoring"

[spec]
precedence = 300
agentKind = "telegraf" # telegraf | elastic-agent | vector
agentVersions = ">=1.38" # optional semver requirement on the agent version
bundles = ["procstat-tomcat"] # bundle instances are merged at this layer's precedence
routes = ["metrics-prod"] # attach routes (outputs) to matching agents
selector = { matchLabels = { role = "tomcat" } }

[spec.tags] # global_tags (Telegraf) / fields (Elastic Agent); none for Vector
service = "tomcat"

[spec.instances."inputs.jolokia2_agent.tomcat".options]
urls = ["http://localhost:8778/jolokia"]

[[spec.instances."inputs.jolokia2_agent.tomcat".options.metric]]
name = "tomcat_threads"
mbean = "Catalina:name=*,type=ThreadPool"
paths = ["currentThreadCount", "maxThreads"]
tag_keys = ["name"]

[spec.instances."inputs.procstat.legacy"]
ensure = "absent" # knock out an instance set by a lower layer
  • Agent kind. A layer applies to the instances of one agent kind on the hosts its selector matches, optionally only to versions matching agentVersions. Shared intent across kinds is one layer per kind.
  • Precedence is an integer; higher wins. Two matching layers with the same precedence that set the same instance key are a validation error.
  • Recommended bands: 100 base, 200 location, 250 location-tier, 300 role, 400 app/hostgroup, 900 node. Bands are convention only.
  • A node layer is a layer whose selector is matchLabels = { "host.name" = "…" }.
  • Instance keys contain dots and are therefore quoted table keys ([spec.instances."inputs.cpu"]); the canonical writer quotes them.

3.2 PluginInstance​

The value under instances."<key>":

FieldTypeMeaning
ensurepresent (default) | absentabsent removes the key.
optionstablePlugin options, as the agent expects them, as a typed tree.
mergetable path → directivePer-path merge directives (§4).
rawstringEscape hatch: native snippet (TOML for Telegraf and Vector, JSON for Elastic Agent) rendered verbatim inside the instance after the typed options (Telegraf rules: telegraf.md §4). Not merged.
notesstringFree text, shown in explain and review.

Instance keys are <section>.<plugin>[.<name>]:

  • Telegraf: inputs.cpu, inputs.http_response.portal, outputs.prometheus_client.local, processors.rename.ecs, aggregators.basicstats.p95, secretstores.os.local. The renderer sets Telegraf's alias to <plugin>.<name> so logs and internal metrics are attributable to the instance.
  • Elastic Agent: inputs.<id> (with options.type = filestream, winlog, system/metrics, …), and outputs.<name> only via routes. Agent settings are settings.agent, settings.agent.monitoring, … (see agents/elastic-agent.md).
  • Vector: sources.<type>[.<name>], transforms.<type>[.<name>], sinks.<type>[.<name>], enrichment_tables.<type>[.<name>], e.g. sources.file.nginx, transforms.remap.app_nginx; the name (else the type) is the component id that inputs refer to. Global options are settings.global (agents/vector.md §4).
  • Telegraf [agent] and [global_tags] are settings.agent and the layer's tags.

Key segments are [a-z0-9_]+ for the plugin and [a-z0-9_-]+ for the name.

3.3 Bundle​

A named, reusable set of instances of one agent kind, without a selector. Layers of the same kind include it with bundles = ["name"]; its instances merge at the including layer's precedence, provenance bundle:<name>@layer:<layer>.

kind = "Bundle"

[metadata]
name = "procstat-tomcat"
owner = "team-middleware"

[spec]
agentKind = "telegraf"

[spec.instances."inputs.procstat.tomcat".options]
pattern = "org.apache.catalina.startup.Bootstrap"
pid_finder = "pgrep"

3.4 Route​

A named output owned by the platform team. Layers attach routes; Elastic Agent inputs refer to them with use_output, Vector routes name their inputs (usually a wildcard such as "prod_*").

kind = "Route"

[metadata]
name = "logs-kafka"
owner = "team-platform"

[spec]
agentKind = "elastic-agent"
usableBy = { teams = ["*"] } # who may attach this route in their layers

[spec.output]
type = "kafka"
hosts = ["kafka-1:9093", "kafka-2:9093"]
topic = "logs-%{[data_stream.dataset]}"
sasl = { mechanism = "SCRAM-SHA-512" }
username = "filebeat"
password = { secretRef = "kafka_logs_password" }
kind = "Route"

[metadata]
name = "metrics-prod"
owner = "team-platform"

[spec]
agentKind = "telegraf"

[spec.output]
plugin = "outputs.http"

[spec.output.options]
url = "https://metrics-gw.example.com/api/v1/push"
data_format = "prometheusremotewrite"
namepass = ["*"]
headers = { Authorization = { secretRef = "metrics_gw_token", format = "Bearer {}" } }
kind = "Route"

[metadata]
name = "logs-prod"
owner = "team-platform"

[spec]
agentKind = "vector"

[spec.output]
plugin = "sinks.http"

[spec.output.options]
uri = "https://logs.example.com/ingest"
inputs = ["prod_*"] # sources and transforms of the agent whose id matches
encoding = { codec = "json" }
auth = { strategy = "bearer", token = { secretRef = "logs_token" } }

3.5 Secret (metadata)​

kind = "Secret"

[metadata]
name = "snmp_v3_auth"
owner = "team-network"

[spec]
minAssurance = "high" # low | high
grant = { matchLabels = { role = "snmp-poller" } } # hosts allowed to receive the value
constraints = { charset = "printable" } # rejects values unsafe for textual substitution

The secret name must match [A-Za-z0-9_]+ (Telegraf secret-store key rule). Values are written through the API or stratactl secret set and never appear in the spec. The Secret at the latest revision governs its values and their delivery (architecture.md §11). References in options use { secretRef = "<name>" }, optionally with a format template ("Bearer {}").

A value can be written once the Secret exists at the latest revision, and a revision that references a Secret validates only while the Secret has a current version. Introducing a secret is therefore two change sets: the Secret object, then, after its value is set, the references.

3.6 Journey​

A synthetic browser journey with its script inline, run by strataplane-agent runners that match its selector. Defined in journeys.md §2.

3.7 RolloutPolicy​

kind = "RolloutPolicy"

[metadata]
name = "default"
owner = "team-platform"

[spec]
appliesTo = { agentKinds = ["telegraf", "elastic-agent", "vector"] }

[[spec.stages]]
name = "non-prod"
selector = { matchExpressions = [{ key = "stage", operator = "NotIn", values = ["prod"] }] }
bakeTime = "15m"

[[spec.stages]]
name = "prod-canary"
selector = { matchLabels = { stage = "prod", canary = "true" } }
bakeTime = "30m"

[[spec.stages]]
name = "rest"
selector = {} # the last stage takes every remaining agent

[spec.guardrails] # checked after each stage's bake time; a breach halts
minInSyncRatio = 0.98 # applied == desired
maxHealthRegression = 0.01 # newly failed/degraded share of the stage
maxNotReporting = 0.02

A stage takes the agents of the rollout's kind whose artifact differs between from and to revision, that its selector matches and that no earlier stage took; other agents and pinned agents are never part of a stage. The last stage's selector is {}, so every such agent is staged (assignment rules: rollout_state). Canaries are chosen with a label. A rollout of an agent kind uses the policy whose appliesTo.agentKinds lists that kind, unless the operator names another; without one it is a single stage of all impacted agents.

3.8 Settings​

The tenant's settings, one object per topic, named after it, without a selector. They change through change sets like any spec file. Render inputs (renderLevel, the telegraf heartbeat and status, the vector heartbeat) reach agents with the revision, like layers; everything else takes effect at merge.

kind = "Settings"

[metadata]
name = "telegraf"
owner = "team-platform"

[spec]
watchInterval = "1m" # the agents' --config-url-watch-interval, for sync detection
heartbeat = { interval = "1m", logLevel = "warn", logLimit = 50 }

[spec.status] # CEL for the heartbeat status
fail = "…"
warn = "…"
ObjectFields
telegrafrenderLevel, watchInterval, heartbeat, status (agents/telegraf.md §4)
elastic-agentrenderLevel, pollTimeout, how long checkins are held (default 4m; agents/elastic-agent.md §2)
vectorrenderLevel, pollInterval (the agents' poll_interval_secs, written into the bootstrap at enrollment and used for sync detection, default 1m), heartbeat (interval, logLevel, logLimit; agents/vector.md §4)
journeysrenderLevel (of strataplane-agent), metricLabels (host labels copied onto journey metrics), failedRunsKept (failed and error runs per journey, default 20), passedRunsKept (passed runs per journey, default 200) (journeys.md §6)
retentionstatusEvents (default 90d), agentLogs (7d), secretDeliveries (365d), artifactsUnreferenced (30d), silentAgents (at least 1d; absent: silent agents are kept): agents that report nothing for this long are forgotten like agent forget, and so are the hosts they leave empty (agent_instance)
assistantdefaultModel (<provider>/<model>), promptAddendum (appended to every system prompt; assistant.md)

renderLevel selects the renderer behaviour of the agent kind; absent means 1 (architecture.md §4).

3.9 Placement​

Picks a number of online hosts and gives them a label, so layers can put extra work on a few hosts of a large group without naming them. Typical use: network probes on five members of a Telegraf class.

kind = "Placement"

[metadata]
name = "netprobe"
owner = "team-network"

[spec]
among = { matchLabels = { class = "notebooks" } } # candidate hosts
count = 5 # per value of `spread`, if set
spread = "zone" # optional label key
label = { role = "netprobe" } # set on the chosen hosts
grace = "15m" # offline time before a replacement (default)
  • Candidates are the hosts that among matches, evaluated without placement labels, with at least one approved, unquarantined agent that reported within its expected interval, and without a manual label of the Placement's key (it would hide the pick).
  • Picking. The placement controller keeps count chosen hosts (per value of spread), drawn at random from the candidates. A chosen host stays until it leaves among or reports nothing for grace; then a candidate replaces it. A lower count drops the most recent picks; with too few candidates, all are chosen and the shortfall is shown.
  • Effect. The chosen hosts carry label with source placement (label_source), and layers select them like any label. Each pick and replacement is a label change: it re-renders the affected agents without a change set or rollout, as a manual label does, and is audited. Class members picked this way get their own config by address (agents/telegraf.md).
  • A Placement takes effect at merge and is approved like any change (§6). Two Placements cannot set the same label key, so each placement label names its Placement; placement labels whose key no Placement at the latest revision sets are removed.

4. Merge semantics​

Instances with the same key are merged in ascending precedence (lower first, higher overrides):

Value typeDefaultDirectives
tabledeep merge by keyreplace (take the higher layer's table as is), remove
scalarhigher replaces lowerremove
arrayhigher replaces lowerappend, remove

Directives are set per path relative to options:

[spec.instances."inputs.procstat.tomcat".options]
namepass = ["procstat_lookup"]

[spec.instances."inputs.procstat.tomcat".merge]
namepass = "append" # add to the lower layer's list instead of replacing it
"tagpass.env" = "remove" # delete this key from the merged result
  • ensure = "absent" at any layer removes the instance, whatever lower layers set; a still higher layer may re-add it with ensure = "present".
  • Provenance. Each leaf records (object, file, precedence, directive). explain prints the winning value and the overridden ones:
$ stratactl explain web01.example.com inputs.procstat.tomcat.options.namepass
inputs.procstat.tomcat.options.namepass = ["procstat", "procstat_lookup"]
← layers/nodes/web01.example.com.toml (900, append) ["procstat_lookup"]
← bundles/procstat-tomcat.toml via layers/role-tomcat.toml (300) ["procstat"]

5. Literal values​

Spec values are literal. Values that differ per group come from layers selected by labels; per-host values come from the agent itself (Telegraf's host tag, Elastic Agent's host metadata) or from agent-native placeholders (${ENV} and @{store:key} for Telegraf, ${var} for Elastic Agent), which pass through untouched.

6. ApprovalPolicy​

kind = "ApprovalPolicy"

[metadata]
name = "prod-four-eyes"
owner = "team-platform"

[spec]
anyHost = { matchLabels = { stage = "prod" } } # matched against the impact set

The impact set holds the agents whose artifact changes, for a changed Secret the agents that reference it, whose delivery its new grant or minAssurance may change, and for a changed Placement the agents on the hosts it may label. A change set whose impact set contains a host matching any policy's anyHost needs:

  • four eyes: the author cannot approve;
  • owner teams: one approver from the owner team of every affected host and of every changed file (so route and settings changes need the platform team).

A change set that matches no policy merges after validation without approval. The computed requirements are stored on the change set, so the review shows why an approval is needed. Changes to policies/ and settings/ always require an admin approval, since much of their effect has no impact set. A current reject blocks the merge (change_set_state).

7. Validation levels​

LevelWhenWhat
L0 syntaxevery writeTOML 1.0, parsing limits (§1.1), canonical form, apiVersion/kind, names, key grammar
L1 specevery writereferences (bundles, routes, secrets) exist, and bundles and routes have the layer's agent kind; selector and agentVersions syntax; placement label keys are unique and not built-in; precedence ties; ownership; at most one RolloutPolicy per agent kind, whose last stage selects every host; no deleted Secret is referenced by a revision that agents receive (a release pointer, an active pin or rollout)
L2 schemapreview, validate, submitevery rendered artifact of the impact set against the plugin catalog of the agent's version: unknown plugins, unknown options, types, enums, required options; journey scripts parse, call only the journey API and assert nothing constant (app.assert of a literal)
L3 sandboxsubmit, before mergeagent binary of the matching version checks the rendered artifact (telegraf config check; Elastic Agent policy check)

L2 and L3 run per distinct (artifact hash, agent version), so a change touching 5,000 agents with three versions and two distinct renders is six checks, not 5,000.