Validating Documents with Rule Schemas

Typed getters can verify one setting at a time. Validation rules describe the contract for an entire configuration tree: which paths may appear, which types they hold, which values are optional or secret, and which constraints each value must satisfy. Validation can also insert declared defaults and attach rule metadata for later diagnostics or presentation.

Erbsland Core supports the same validation model from two sources. You can write a readable ELCL rules document and compile it into the application, or construct the rules directly in C++ with RulesBuilder. Both approaches produce an immutable Rules object that can validate many documents.

The ELCL validation-rules documentation is the language reference for rule-document structure, rule types, constraints, alternatives, versions, dependencies, and custom diagnostics. This page focuses on loading and applying those rules with Erbsland Core.

Embed a Rules Document

A separate .elcl file keeps a substantial schema approachable in reviews and lets tools process it without understanding C++. For an application-owned schema, compile that file into the executable with the resource system. This preserves the source file as a normal project artifact while avoiding a deployment-time search path or a mismatch between the executable and its rules.

The demo embeds this rules document:

[survey]
type: "section"
title: "Levantamento da floresta"

[survey.forest]
type: "text"
minimum: 3
maximum: 80

[survey.observers]
type: "integer"
minimum: 1
maximum: 12

[survey.region]
type: "text"
default: "Mata Atlântica"

[survey.api_token]
type: "text"
is_optional: yes
is_secret: yes

Each section path identifies a node in the application document. The type attribute chooses its ELCL type; other attributes add metadata or behavior. Constraints such as minimum and maximum narrow accepted content according to the rule type, so here they bound text length for forest and numeric value for observers.

Add the directory to the demo target with erbsland_core_add_resources as described in Compiling Resources into an Application or Library:

erbsland_core_add_resources(
        TARGET validation_rules
        DIRECTORY "${CMAKE_CURRENT_LIST_DIR}/data"
        IDENTIFIER "conf-validation-rules"
        SUFFIXES ".elcl"
)

At runtime, read the text from Application::resources(), parse it as a normal configuration document, and pass the result to Rules::createFromDocument(). The factory checks that the document itself is a valid rule schema. A malformed definition therefore fails during application setup, before it is applied to user configuration.

conf/ValidationRules/EmbeddedRules.cpp

/// Load and apply an ELCL validation-rules document embedded in the executable.
///
/// Compiled resources keep a readable rules file beside the source code without introducing a deployment-time path.
/// Parse that text, create a reusable `Rules` instance, and validate each application document before reading values.
void embeddedRules() {
    // Load the rules document from the application's compiled resources.
    const auto ruleText =
        el::application().resources().getTextOrThrow("conf-validation-rules"_el, "survey-rules.elcl"_el);
    const auto ruleDocument = el::conf::Parser{}.parseTextOrThrow(ruleText);
    const auto rules = el::conf::vr::Rules::createFromDocument(ruleDocument);

    const auto configuration = "[survey]\n"
                               "forest: \"Parque Nacional da Tijuca\"\n"
                               "observers: 8\n"_el;
    const auto document = el::conf::Parser{}.parseTextOrThrow(configuration);

    // Validation checks the shape and constraints, then adds configured default values.
    rules->validate(document, 1);
    el::io::printLine("Forest: "_el, document->getTextOrThrow("survey.forest"_el));
    el::io::printLine("Region: "_el, document->getTextOrThrow("survey.region"_el));
}

Output

Forest: Parque Nacional da Tijuca
Region: Mata Atlântica

Call Rules::validate() only after the application document has been parsed and before its values are interpreted. The integer argument selects the application’s configuration-schema version. Even when the first schema has no version-specific rules, passing its explicit version keeps the loading contract ready for later evolution.

Validation stops at the first violation and throws ConfError with the relevant path and location. On success, defaults such as survey.region are present in the document and ordinary typed getters can read the validated tree. Create the rules once during setup and reuse them when the application loads or reloads multiple documents.

Build Rules into C++

A schema that closely follows compiled application logic can be expressed with RulesBuilder. This approach makes rule types and builder arguments visible to the C++ compiler and removes the parsing step. It is especially convenient for compact schemas, library-owned configuration branches, or generated rule definitions.

RulesBuilder::addRule() associates a name path with a RuleType and any number of builder attributes. Attributes such as Title, Default, IsOptional, and IsSecret describe metadata or behavior. Constraint attributes such as Minimum and Maximum restrict accepted values.

conf/ValidationRules/BuiltRules.cpp

/// Build and apply validation rules directly in C++.
///
/// `RulesBuilder` combines each rule type with attributes and constraints. Attributes describe behavior or metadata;
/// constraints narrow the accepted value. Finalization checks the definition and returns an immutable, reusable rule
/// set.
void builtRules() {
    using el::conf::vr::RuleType;
    using el::conf::vr::builder::Default;
    using el::conf::vr::builder::Description;
    using el::conf::vr::builder::IsOptional;
    using el::conf::vr::builder::IsSecret;
    using el::conf::vr::builder::Maximum;
    using el::conf::vr::builder::Minimum;
    using el::conf::vr::builder::Title;

    auto builder = el::conf::vr::RulesBuilder{};
    builder.configureRoot(Title{"Levantamento florestal"_el});
    builder.addRule("survey"_el, RuleType::Section, Title{"Levantamento da floresta"_el});
    auto survey = builder.createBranch("survey"_el);
    survey.addRule("forest"_el, RuleType::Text, Description{"Nome da floresta observada"_el}, Minimum{3}, Maximum{80});
    survey.addRule("observers"_el, RuleType::Integer, Minimum{1}, Maximum{12});
    survey.addRule("region"_el, RuleType::Text, Default{"Mata Atlântica"_el});
    survey.addRule("api_token"_el, RuleType::Text, IsOptional{}, IsSecret{});
    const auto rules = builder.takeRules();

    const auto configuration = "[survey]\n"
                               "forest: \"Floresta Nacional de Ipanema\"\n"
                               "observers: 6\n"_el;
    const auto document = el::conf::Parser{}.parseTextOrThrow(configuration);
    rules->validate(document, 1);

    el::io::printLine("Validated observers: "_el, document->getIntegerOrThrow("survey.observers"_el));
    el::io::printLine("Default region: "_el, document->getTextOrThrow("survey.region"_el));
}

Output

Validated observers: 6
Default region: Mata Atlântica

The document root has an implicit rule. Use createBranch() to pass a subtree to a helper or add multiple children without repeating their parent path. Branches can be nested and passed as RulesBuilder&. They share the builder backend; declare parent rules explicitly and finalize the schema through the main builder.

Use configureRoot() when the root itself needs a title, description, dependency, version range, or another compatible attribute. Use addAlternative() when one path deliberately accepts several rule definitions.

takeRules() finalizes the schema and resets the builder for reuse. Finalization checks the relationships between definitions and rejects incompatible attributes, invalid references, and other logical errors before configuration validation begins. The resulting Rules object is applied exactly like one created from a rules document, so the choice of source does not spread into the rest of the loading code.

Choose the Rule Source

Prefer an embedded ELCL document when people need to read or maintain a substantial schema independently of C++. It remains normal text in the source tree, while compiled resources keep deployment reliable. Prefer RulesBuilder when a compact schema belongs directly to a component’s implementation or when C++ code generates definitions from another model.

Applications can also combine these approaches at subsystem boundaries. For example, the main executable can own an embedded application schema while a reusable component exposes a built rule set for the configuration branch it understands. Whichever source you choose, finalize the rules during setup, validate the complete input, and only then interpret the document.

Continue with Validated Values

Successful validation leaves useful information on each value: the matching rule, secret status, default status, and whether the value passed through validation at all. The next topic explains how application diagnostics, configuration dumps, and editors can use this metadata safely.

Complete demo sources

conf/ValidationRules/EmbeddedRules.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include "ValidationRulesDemos.hpp"

#include <erbsland/conf/Parser.hpp>
#include <erbsland/conf/vr/Rules.hpp>
#include <erbsland/Resources.hpp>
#include <erbsland/StandardStreams.hpp>

namespace demo {

/// Load and apply an ELCL validation-rules document embedded in the executable.
///
/// Compiled resources keep a readable rules file beside the source code without introducing a deployment-time path.
/// Parse that text, create a reusable `Rules` instance, and validate each application document before reading values.
void embeddedRules() {
    // Load the rules document from the application's compiled resources.
    const auto ruleText =
        el::application().resources().getTextOrThrow("conf-validation-rules"_el, "survey-rules.elcl"_el);
    const auto ruleDocument = el::conf::Parser{}.parseTextOrThrow(ruleText);
    const auto rules = el::conf::vr::Rules::createFromDocument(ruleDocument);

    const auto configuration = "[survey]\n"
                               "forest: \"Parque Nacional da Tijuca\"\n"
                               "observers: 8\n"_el;
    const auto document = el::conf::Parser{}.parseTextOrThrow(configuration);

    // Validation checks the shape and constraints, then adds configured default values.
    rules->validate(document, 1);
    el::io::printLine("Forest: "_el, document->getTextOrThrow("survey.forest"_el));
    el::io::printLine("Region: "_el, document->getTextOrThrow("survey.region"_el));
}

}

conf/ValidationRules/BuiltRules.cpp

// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0

#include "ValidationRulesDemos.hpp"

#include <erbsland/all_conf_vr.hpp>
#include <erbsland/all_conf_vr_builder.hpp>
#include <erbsland/conf/Parser.hpp>
#include <erbsland/StandardStreams.hpp>

namespace demo {

/// Build and apply validation rules directly in C++.
///
/// `RulesBuilder` combines each rule type with attributes and constraints. Attributes describe behavior or metadata;
/// constraints narrow the accepted value. Finalization checks the definition and returns an immutable, reusable rule
/// set.
void builtRules() {
    using el::conf::vr::RuleType;
    using el::conf::vr::builder::Default;
    using el::conf::vr::builder::Description;
    using el::conf::vr::builder::IsOptional;
    using el::conf::vr::builder::IsSecret;
    using el::conf::vr::builder::Maximum;
    using el::conf::vr::builder::Minimum;
    using el::conf::vr::builder::Title;

    auto builder = el::conf::vr::RulesBuilder{};
    builder.configureRoot(Title{"Levantamento florestal"_el});
    builder.addRule("survey"_el, RuleType::Section, Title{"Levantamento da floresta"_el});
    auto survey = builder.createBranch("survey"_el);
    survey.addRule("forest"_el, RuleType::Text, Description{"Nome da floresta observada"_el}, Minimum{3}, Maximum{80});
    survey.addRule("observers"_el, RuleType::Integer, Minimum{1}, Maximum{12});
    survey.addRule("region"_el, RuleType::Text, Default{"Mata Atlântica"_el});
    survey.addRule("api_token"_el, RuleType::Text, IsOptional{}, IsSecret{});
    const auto rules = builder.takeRules();

    const auto configuration = "[survey]\n"
                               "forest: \"Floresta Nacional de Ipanema\"\n"
                               "observers: 6\n"_el;
    const auto document = el::conf::Parser{}.parseTextOrThrow(configuration);
    rules->validate(document, 1);

    el::io::printLine("Validated observers: "_el, document->getIntegerOrThrow("survey.observers"_el));
    el::io::printLine("Default region: "_el, document->getTextOrThrow("survey.region"_el));
}

}