Tech5/30/2026·9 min

Mechanism vs Policy: A Remote-Config DSL the Compiler Turns Into a Cross-End Contract

Our remote config was YAML with no build-time semantics: a product could configure a ghost key, pick a value out of range, or split experiment weights that didn't sum to 100. Splitting mechanism from policy into two .rc dialects let the compiler reject all of that before runtime.

The gap: YAML had no contract

Remote config in the repo started as two YAML files with split responsibilities — a module-side file that declared keys and a product-side file that set values. Nothing connected them at build time. The product YAML could:

  • reference a key the module never declared (a ghost key that silently does nothing),
  • assign a value whose type was only inferred, never checked,
  • set a rollout percentage outside [0, 100],
  • declare an experiment whose variant weights didn't sum to 100.

All of those are caught at runtime, if at all — usually as a config that quietly has no effect on some fraction of installs. That violates a principle the repo holds elsewhere: if the build can catch it, the build should catch it, not the runtime assuming its input is valid.

The replacement is a DSL with two dialects: .def.rc for mechanism (what a knob can be controlled into) and .ctl.rc for policy (what a product does control it into). The split is the entire reason for building a language instead of adding more YAML lint: a separate dialect lets the compiler act as the referee between the two.

Mechanism: declaring the boundary

A .def.rc catalog declares each knob's type, domain, and — the load-bearing part — its policy surface:

config isFeatureEnabled: bool {
    default     = false
    description = "Feature toggle"
    policy      = rollout
}

config maxRetries: int {
    default     = 3
    range       = 0...10
}

policy is a lattice, ordered static < overridable < rollout < experimentable. Each tier permits everything below it:

// ast.rs
pub enum Policy { Static, Overridable, Rollout, Experimentable }

impl Policy {
    pub fn allows_override(self) -> bool  { !matches!(self, Policy::Static) }
    pub fn allows_rule(self) -> bool      { matches!(self, Policy::Rollout | Policy::Experimentable) }
    pub fn allows_experiment(self) -> bool { matches!(self, Policy::Experimentable) }
}

A static knob can't be touched. An overridable knob accepts a product default but no targeting. A rollout knob (the tier a kill-switch uses) accepts override plus targeting/rollout rules. experimentable adds A/B experiments. The mechanism author decides the ceiling; the product cannot reach above it.

Type and domain are likewise the mechanism's to set: int/double with a range, string with a regex, enum over a closed member set, plus a json escape hatch. visibility = internal marks a knob a product may not control at all (it's injected by a higher layer).

Policy: control within the contract

A .ctl.rc opens with use <catalog>, then exercises control:

product "doodlemotion"
use video_generation

override isFeatureEnabled = false
override maxRetries = 3

rule isFeatureEnabled {
    when app_version >= "2.0.0"
    rollout 50% by install_id salt "vidgen-rollout-1"
    value = true
}

use defines a closed world: the only configs visible are those in the named catalogs (which are the Bazel deps). Everything the product writes is adjudicated against that world, key by key.

The compiler is the referee — three phases, scoped invariants

The design rule is to put each constraint in the smallest scope that can see it. That maps to three compiler phases, three CLI subcommands, three Bazel macros.

P1 — catalog (per module). check_catalog validates a .def.rc is self-consistent: duplicate keys, required xor default, defaults in domain, enum members non-empty and unique, range lo <= hi, regex only on strings and compilable. It then lowers the catalog to a descriptor — a machine-readable serde JSON of the published interface — which P2 loads. Value checking (does this literal satisfy the type and domain?) lives in one shared core, coerce_value, reused by both P1 (catalog default) and P2 (product values), so the rule has a single source of truth:

// value.rs — int path
Value::Num(n) if n.fract() == 0.0 => {
    check_range(*n, domain)?;
    // f64 -> i64 saturates silently; reject out-of-range literals here so a
    // wrong value never reaches the snapshot or the emitted Swift default.
    if *n < i64::MIN as f64 || *n >= 9_223_372_036_854_775_808.0 {
        return Err(ValueError::Range { value: *n, lo: i64::MIN as f64, hi: i64::MAX as f64 });
    }
    Ok(J::Number((*n as i64).into()))
}

P2 — product (per product). link(control, descriptors) builds a symbol table from the used catalogs and adjudicates each override, rule, and experiment. The error taxonomy is the contract made executable:

  • UnknownConfig — the original motivation: a product referencing a key no catalog defines.
  • TypeMismatch / DomainRange / DomainEnum / DomainRegex — value outside the declared type or domain.
  • PolicyNotAllowed — a targeting rule on an overridable knob, or an experiment on a rollout knob.
  • InternalKnobOverridden — a product touching an internal knob.
  • MissingRequiredDecision — a required knob with no product override.
  • DuplicateSalt / ConflictingDriver — a salt reused, or a config driven by both a rule and an experiment.
  • VariantWeightSum — experiment variant weights not summing to 100 (within a 1e-6 tolerance, because three-way decimal splits like 33.33/33.33/33.34 accumulate float rounding and a one-ULP tolerance would reject them).
  • UnknownDimension — targeting on a dimension outside the whitelist app_version / os / os_version / region / env.
  • UnsupportedStickyBy — bucketing by anything other than install_id, which is all the runtime evaluator supports; without the check the DSL could name another bucket key that runtime silently ignores.

The last two are the same idea: the compiler refuses to emit something the runtime would silently drop. The dimension whitelist mirrors exactly what the evaluator accepts; the sticky-key whitelist mirrors what it buckets on.

P3 — fleet (one gate). Two classes of invariant are invisible to any single product compile, so they sit in their own pass over all catalogs' descriptors plus the per-product manifests P2 emits:

  • CrossProductSalt — the same salt used by two products, which breaks bucketing independence across them.
  • DuplicateKey — the same namespace.key defined in two catalogs.

P3 also reconciles the config control graph against the code graph. A Bazel aspect walks an app target's transitive deps, collecting catalogs tagged remote_config_catalog=<name>, and emits a linkage file — at analysis time, without compiling Swift. check_linkage then enforces that if an app links a catalog with a required knob, the product must decide it (RequiredNotCovered, fatal), and warns when a product controls a catalog its app never links (orphan control).

Drop-in, by construction

The DSL replaces authoring and codegen only. The snapshot it emits for the parameters shape is byte-compatible with the old serde_yaml path — same field names, 2-space pretty, trailing newline, params sorted by (namespace, key) — so everything downstream (the embedded aggregate, the Go registry, the evaluator) is untouched. The example BUILD.bazel carries a diff gate proving the DSL snapshot equals the YAML snapshot byte-for-byte, plus a fleet_check gate, so the equivalence is verified on every build rather than asserted.

The implementation is a single Rust crate, remoteconfig_dsl, mirroring the structure of the metric codegen (lexer -> parser -> ir -> emit, inline #[cfg(test)], one rust_test over the lib): roughly 4,000 lines across lexer/parser/ast/value/ir/link/fleet/emit, with 122 test functions and measured file-level coverage at or above 95%.

What transfers

The reusable shape isn't the DSL — it's where the constraints live. Split a configuration system into what can be controlled (mechanism) and what is controlled (policy), give each its own input, and a compiler can adjudicate one against the other. Then scope every invariant to the smallest pass that can see it: per-module facts in P1, per-product decisions in P2, fleet-global uniqueness in P3. The payoff is that a ghost key, an out-of-range value, an over-reaching rollout, or a cross-product salt collision become build errors with names — not a config that quietly does the wrong thing for some installs.