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;Secretnames are^[A-Za-z0-9_]+$instead. - Every object has
apiVersion = "v1alpha1",kind, and a[metadata]table withname,owner(a team slug) and optionaldescriptionandlabels. - Files are canonicalised on write, so diffs stay minimal and a revision hash
is stable:
apiVersionandkindfirst, 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:
| Limit | Value |
|---|---|
| File size | 1 MiB |
| Nesting depth | 32 tables/arrays |
| Keys per file | 10,000 |
| String length | 256 KiB (journey scripts are the largest values) |
| Duplicate keys, redefined tables | error (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" },
]
| Operator | Key absent | Key present with value v |
|---|---|---|
In / NotIn | false / true | v in / not in the values |
Exists / DoesNotExist | false / true | true / false |
Glob / NotGlob | false / true | v matches any / no pattern |
Gt, Lt | false | v is an integer and v > / < the value; otherwise false |
selector = {}matches every host.matchLabels { k = v }isInwith one value.In,NotIn,GlobandNotGlobneed at least one value;GtandLtexactly one integer. Integers are-?[0-9]+, compared without a size limit; a label value that is not an integer never matchesGtorLt.- 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 exampleos=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.labelsJSONB 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:
100base,200location,250location-tier,300role,400app/hostgroup,900node. 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>":
| Field | Type | Meaning |
|---|---|---|
ensure | present (default) | absent | absent removes the key. |
options | table | Plugin options, as the agent expects them, as a typed tree. |
merge | table path → directive | Per-path merge directives (§4). |
raw | string | Escape 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. |
notes | string | Free 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'saliasto<plugin>.<name>so logs and internal metrics are attributable to the instance. - Elastic Agent:
inputs.<id>(withoptions.type=filestream,winlog,system/metrics, …), andoutputs.<name>only via routes. Agent settings aresettings.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 thatinputsrefer to. Global options aresettings.global(agents/vector.md §4). - Telegraf
[agent]and[global_tags]aresettings.agentand the layer'stags.
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 = "…"
| Object | Fields |
|---|---|
telegraf | renderLevel, watchInterval, heartbeat, status (agents/telegraf.md §4) |
elastic-agent | renderLevel, pollTimeout, how long checkins are held (default 4m; agents/elastic-agent.md §2) |
vector | renderLevel, 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) |
journeys | renderLevel (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) |
retention | statusEvents (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) |
assistant | defaultModel (<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
amongmatches, 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
countchosen hosts (per value ofspread), drawn at random from the candidates. A chosen host stays until it leavesamongor reports nothing forgrace; then a candidate replaces it. A lowercountdrops the most recent picks; with too few candidates, all are chosen and the shortfall is shown. - Effect. The chosen hosts carry
labelwith sourceplacement(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 type | Default | Directives |
|---|---|---|
| table | deep merge by key | replace (take the higher layer's table as is), remove |
| scalar | higher replaces lower | remove |
| array | higher replaces lower | append, 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 withensure = "present".- Provenance. Each leaf records
(object, file, precedence, directive).explainprints 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
| Level | When | What |
|---|---|---|
| L0 syntax | every write | TOML 1.0, parsing limits (§1.1), canonical form, apiVersion/kind, names, key grammar |
| L1 spec | every write | references (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 schema | preview, validate, submit | every 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 sandbox | submit, before merge | agent 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.