Stop Guessing at Deploy Time: Contracts, Preflight, and Policy-as-Code for Multi-Container Config
Subtitle: How we moved from scattered scripts to a single configuration plane that verifies schemas, pins digests, and runs module-owned Rego before anything starts.
Estimated read time: 7 minutes
Preflight is not a lint pass on YAML. It is the moment the platform commits to exact bytes of contract and policy — and refuses to start until that commitment checks out.
The problem we were tired of
In many telecom and edge stacks, “configuration” still means a patchwork: bootstrap files, per-process stores, control-channel heartbeats, and validation rules copied into three different codebases. A parameter changes in one place; a downstream container never hears about it until something misbehaves on the air interface.
We wanted one configuration plane: a single writer, namespaced subscribers (one namespace per module instance), and revisioned snapshots so a module can tell “this notice applies to me” from “I need a full refresh.” That architecture is a story for another post. Here we focus on what makes bring-up trustworthy: preflight and policy-as-code.
If a workload starts with the wrong PLMN shape, an out-of-range physical cell ID, or a calculated field that no longer matches its inputs, you want to know before the process binds sockets — not after operators stare at a red cell on a map.
Three layers of validation (and only one uses OPA)
A common mistake is throwing every rule into Rego. We split validation by who owns the rule and what kind of check it is:
Wire / API shape — guards JSON messages on the config line. Use JSON Schema on the protocol.
Owner document shape — guards the module’s requirement schema (fields, bindings, types). Validate against a JSON Schema platform contract at preflight.
Semantic / domain rules — guards PLMN consistency, cell parameters, cross-field logic, and similar domain truth. Use inline min/max/pattern or Rego via Open Policy Agent (OPA).
The central service does not use OPA to validate JSON syntax or to prove that an owner schema document is well-formed. Those stay in JSON Schema land — fast, familiar, and easy to test in CI.
OPA enters when a module owner attaches a policy block to a field (or constraint) in their requirement schema. The platform discovers the bundle, verifies digests, runs opa check, caches the bundle, and later calls opa eval with a stable JSON input document at validation time.
JSON Schema answers “is this document structurally allowed?” Rego answers “given what we know about the network, is this value actually sane?”
What preflight actually does
Preflight is a single pipeline that runs before value resolution and before we allow a module instance to join the bring-up path. Any stage can fail closed: no snapshot, no deploy, no “start anyway and fix in prod.”
Rough flow:
Discover schema pin (from workload manifest)
→ Fetch schema bytes → verify SHA-256
→ Validate document against platform contract
→ Collect policy bundle pins (manifest + schema references)
→ Verify bundle manifest + per-file digests → opa check → cache
→ Resolve field values into the module namespaceIf fetch, digest, contract validation, or policy load fails, bring-up stops with a concrete error — not a silent fallback.
What we mean by “preflight data”
Preflight data is not a mysterious cache of cluster secrets. It is the set of pinned artifacts and the outcomes that only exist after preflight succeeds:
- Pinned owner schema — immutable JSON identified by
schema_id, version, HTTPS (or labfile://) URL, and SHA-256 of the exact body. The document’s internal identity must match the pin. - Pinned policy bundles —
bundle.manifest.jsonlisting each.regofile and its digest; bundle id/version must match the pin. - Resolved configuration snapshot — field values bound from authoritative network config, integration catalog entries, user inputs, and calculated formulas — materialized into the module’s namespace only after the steps above pass.
Until preflight completes, the platform treats the module as not contractually ready. That is the shift from “hope the container starts” to “we proved which contract version we are running.”
Immutable pins: trust the bytes, not the URL
URLs lie. Redirects, stale CDNs, and mistaken tags happen. Our pattern is:
- Publish exact bytes (owner schema JSON, policy bundle directory).
- Record lowercase SHA-256 on the workload manifest (
config-schema-digest,config-policy-bundle-digest, etc.). - At preflight, fetch → hash → compare. Mismatch aborts.
- Verify identity:
schema_id/schema_versioninside the file match the pin; same forbundle_id/bundle_versionin the policy manifest.
In production, module teams co-publish the owner schema with the container image (OCI/ORAS attach is a common pattern): same release artifact, same digest story. The central service reads pins from generated workload manifests; it does not guess schema versions from image tags alone.
Lab demos often use read-only file:// roots constrained to packaged directories so engineers get deterministic offline runs. The trust model is the same—only the transport differs.
Policy-as-code: module owners ship rules; the platform enforces
Who writes Rego? The team that owns the AMF, radio cell, or UE control module — not the platform team encoding every 3GPP edge case in Elixir.
Get Hareesh Pallathoor’s stories in your inbox
Join Medium for free to get updates from this writer.
Who runs OPA? The configuration plane, at two moments:
- Preflight —
opa checkon the bundle so broken policy never lands in cache. - Runtime validation —
opa evalwhen a field with apolicyattachment needs checking, including after edits.
A minimal attachment in the owner schema looks like:
{
"policy": {
"bundle_id": "module.amf.validation",
"bundle_version": "1.0.0",
"file": "plmn_shape.rego",
"query": "data.module.amf.plmn.violations",
"severity": "warning"
}
}When policy is set on a validation object, inline pattern / min / max on that same object are not used for that attachment—the Rego bundle owns semantics for that field.
The OPA input contract
The config line protocol is not sent to OPA. The platform builds one JSON document per evaluation:
component— namespace id, e.g.amf/1path— store path for the field under testentries— map of path to{ "value", "pending" }rule— the validation attachment (ids, severity, policy ref)dependencies— logical inputs the resolver already computed (e.g. MCC/MNC), not raw store paths
Pending entries are skipped inside Rego so half-filled forms do not false-positive.
OPA output → operator-visible validation
Each policy returns a set of violations. The platform flattens them into a validation snapshot:
result:pass,warning, orfailfailures:[{ "rule", "path", "value", "severity", ... }]
Any failure with severity: "fail" fails the snapshot; warnings surface without blocking unless your operations policy says otherwise. On config change, subscribers can receive a notify with the same failure list—so editing PCI, a global synchronization channel number, or a calculated PLMN re-runs policy without a pod restart.
Worked example: PLMN shape and formula consistency
Public Land Mobile Network identifiers often appear as a calculated mcc:mnc string. Two classes of bugs matter:
- Shape — not matching
^\d{3}:\d{2,3}$. - Consistency — string looks fine but does not match the MCC/MNC the resolver pulled from authoritative config.
Module Rego should use input.path (supplied by the platform from the owner binding), not hard-coded store paths—so the same policy survives path layout changes.
package module.amf.plmnimport rego.v1violations contains v if {
input.path
entry := input.entries[input.path]
object.get(entry, "pending", false) == false
not regex.match(`^\d{3}:\d{2,3}$`, sprintf("%v", [entry.value]))
v := {
"rule": "plmn-shape",
"path": input.path,
"value": entry.value,
"severity": object.get(input.rule, "severity", "warning"),
}
}violations contains v if {
input.path
input.dependencies.mcc
input.dependencies.mnc
entry := input.entries[input.path]
object.get(entry, "pending", false) == false
formatted := sprintf("%v", [entry.value])
regex.match(`^\d{3}:\d{2,3}$`, formatted)
expected := sprintf("%v:%v", [input.dependencies.mcc, input.dependencies.mnc])
formatted != expected
v := {
"rule": "plmn-formula",
"path": input.path,
"value": entry.value,
"severity": object.get(input.rule, "severity", "warning"),
"expected": expected,
}
}The same pattern extends to other domains: OAM cell lists (GSCN range, subcarrier spacing sets), PCI for a physical cell ID, and so on — each bundle pinned and versioned independently.
Pragmatic split: simple numeric bounds (e.g. a pointer 0–255) stay as minimum / maximum on the owner schema and inline checks on validation snapshots. Not every rule deserves a .rego file.
Try it on your laptop
You do not need a cluster to feel the policy loop:
# Install OPA from https://www.openpolicyagent.org/docs/latest/#running-opaopa check ./my-bundle/ # preflight-style compile checkopa eval -f json \
-d plmn_shape.rego \
-i plmn-bad-input.json \
data.module.amf.plmn.violationsExample plmn-bad-input.json:
{
"component": "amf/1",
"path": "/calculated/plmn",
"entries": {
"/calculated/plmn": { "value": "bad", "pending": false }
},
"dependencies": { "mcc": "311", "mnc": "489" },
"rule": { "severity": "warning" }
}You should see a plmn-shape violation. Fix the value to 311:489 and the set goes empty—what the platform maps to result: "pass".
Lessons we would repeat
- Separate shape from semantics — JSON Schema for documents and protocol; Rego for domain rules owned by modules.
- Pin everything that defines behavior — schema bytes, bundle manifest, per-policy file digests; hashes, not tags alone.
- Fail closed at preflight — missing required values, digest mismatch, or
opa checkfailure blocks bring-up. - Re-validate on change — policy is not only a gate at t=0; validation snapshots should move with edits.
- Keep the platform thin — central code orchestrates fetch, cache, and eval; module teams ship bundles and test them in CI with
opa check.
Closing thought
Configuration management at cluster scale is as much about governance as about key-value storage. Preflight turns “we think this module speaks v2.0.0” into “we hashed v2.0.0 and refused to lie about it.” Policy-as-code turns “the AMF team’s PLMN rules” into artifacts they version, publish, and stand behind — while the platform stays a consistent enforcement layer.
That combination — contracts, pinned data, and module-owned policy — is how we stopped guessing at deploy time.
If you implement something similar, start with one field, one bundle, and one digest pin. Expand when preflight is boringly reliable.








