Skip to content

Semantic Rules

Petrus Pradella edited this page Aug 5, 2026 · 1 revision

Semantic Rules

A type says what a value is. A rule says what it must mean: a port is a number, but a valid port is 1..65535; a token is a String, but a usable token is one the operator actually filled in.

Three axes, kept orthogonal on purpose:

Axis Where it is declared Example
the fact on the field @Min(0) @Max(100)
the consumer where the config is opened config.withRuleEngine(StandardRules.engine())
the policy per config config.withRulePolicy(RulePolicy.defaults().withCorrections(true))

Because they never entangle, the same declarations serve a validating load, a settings screen that never opens a file, and the comments written into the file itself.

Rules live in the optional everyconfig-rules artifact — except the SEAM (@ConfigRule, RuleEngine, RulePolicy, RuleModel), which is in everyconfig-core and carries no rule of its own. See Installation.

The shape of it

public class ServerConfig {

    @Comment("Port the service binds to.")
    @Min(1) @Max(65535)
    private int port = 25565;

    @Comment("Chance of a drop, in percent.")
    @Min(0) @Max(100)
    private double dropChance = 25.0;

    @Comment("Auth token. Fill it before the service will start.")
    @Explicit @NotBlank
    private String token = "";

    @OneOf(value = {"MONGO", "SQL", ""}, ignoreCase = true)
    private String dbType = "";

    @Unique
    private List<String> enabledWorlds = new ArrayList<>();
}
Config config = Config.open(path, codec)
        .withRuleEngine(StandardRules.engine())                      // jakarta + @Explicit/@OneOf/@Unique
        .withRulePolicy(RulePolicy.defaults().withCorrections(true));

BindResult<ServerConfig> result = config.loadAsResult(ServerConfig.class, codec);
for (LoadIssue issue : result.issues()) {
    if (issue.kind() == LoadIssue.Kind.RULE) {
        log.warn("{}: {}", issue.key(), issue.message());
    }
}
if (config.hasRuleFixes()) {
    config.save();   // persist what the load repaired
}

When they run, and where the result goes

Rules run inside the bind, not before or after it:

Phase When What it judges
VALIDATE between the bind and @PostLoad the entity that was just read
NORMALIZE on write, before the projection to the tree the entity as it stands now

A violation becomes a LoadIssue of Kind.RULE and travels the channel the coercion issues already travelled. That is the point: a @PostLoad written before rules existed sees them without one line of change, and so do LoadIssueAware, loadAsResult(...) and the binder's readResult(...).

@PostLoad
void afterLoad(ConfigContext context) {
    for (LoadIssue issue : context.issues()) {     // coercion AND rule issues, one list
        if (issue.kind() == LoadIssue.Kind.RULE) {
            RuleViolation violation = issue.violation();
            i18n.warn(violation.messageKey(), violation.messageArgs());   // or fall back to message()
        }
    }
}

On the way out, what NORMALIZE finds reaches @PostSave through the same ConfigContext.issues().

The policy: what a violation costs

RulePolicy has three severities — REPORT (becomes a LoadIssue, the bind continues), LOG (the same plus one warning per site, not per load) and THROW (BindException on the first violation).

Which one applies depends on where the value came from:

Origin Default Why
FILE — the file supplied it follows the bind's Coercion: STRICT throws, LENIENT reports a config that already said how strict it is should not say it twice
DEFAULT — the entity's own initializer THROW an entity breaking its own rule is a code defect: no config file can fix it and every run reproduces it
RulePolicy.defaults()
        .withSeverity(RulePolicy.Severity.LOG)             // for FILE data; null = follow the Coercion
        .withDefaultViolations(RulePolicy.Severity.REPORT) // downgrade the code-defect escalation
        .withCorrections(true);                            // let a handler rewrite what it rejected

Every failure message teaches the way out:

Rule @Max(100) at 'dropChance' rejects the file value '150.0'. Fix the value in the file,
or relax the rule on ServerConfig.dropChance.
@Max(100) on ServerConfig.dropChance ('dropChance') rejects the field's OWN DEFAULT value 150.0.
This is a code defect, not user data: no config file can fix it, and every run reproduces it.
Change the field's initializer or relax the rule.

Corrections

With withCorrections(true), a handler may rewrite the value it rejected — clamp instead of complain.

Correcting a value the file supplied rewrites the entity and the canonical tree at that path, then flags Config.hasRuleFixes(). Correcting only the entity would leave the tree holding the value that was just rejected, so the next getValue would return it and the next save() would write it straight back.

The file changes only on an explicit save(). Reading never touches disk — persisting what a load repaired stays your decision.

Correcting does not silence: the handler still reports, so what changed is visible instead of a value quietly differing from the file.

The vocabulary

Jakarta constraints, read natively

jakarta.validation.constraints is read directly — the annotations, and nothing else. No provider, no ServiceLoader, no Bean Validation implementation anywhere on the classpath. Another library carrying a different version of the same annotations is harmless, because these are only ever read.

Group Annotations
Presence @NotNull, @Null, @NotBlank, @NotEmpty
Size @Size, @Digits
Range @Min, @Max, @DecimalMin, @DecimalMax
Sign @Positive, @PositiveOrZero, @Negative, @NegativeOrZero
Text @Pattern, @Email
Boolean @AssertTrue, @AssertFalse
Temporal @Past, @PastOrPresent, @Future, @FutureOrPresent

Declared divergences from Bean Validation — deliberate, not accidental:

  • null passes every constraint except presence. @NotNull/@NotEmpty/@NotBlank reject it, @Null demands it, everything else lets it through. Presence and content are separate rules, composed by whoever declares them; without this, @Max on an absent Integer would fire a phantom violation.
  • The range constraints accept EVERY numeric type, double and float included, comparing in BigDecimal. Bean Validation excludes them for fear of rounding, but a percentage bounded by @Min(0) @Max(100) is the ordinary case in a config file.
  • @Email uses a pragmatic expression, not RFC 5322 (a local part with no whitespace and no @, one @, a domain with at least one dot). Bean Validation fixes no expression, so differing from another provider is expected — this one is stated rather than guessed at.
  • No groups, no payload, no @Valid. The descent into nested types is always on.
  • A message() you wrote by hand replaces the English text; Bean Validation's own {key} template does not.
  • A constraint on a type it cannot judge fails immediately@Size on an int is a defect in the DECLARATION, reproducible on every run, so it throws with a message naming the member and the types that would work rather than passing quietly.

What jakarta cannot say

Annotation What it means
@Explicit the value must come from the FILE, not the entity's default — provenance, not content. A @NotBlank on a field whose default is "changeme" passes; this does not.
@OneOf the value must belong to a known set. Per element on a Collection<String>/String[], ignoreCase optional, and a provider for a set only known at runtime.
@Unique the collection or array holds no repeat. Equality is the elements' own equals(); two nulls count.
@OneOf(value = {"world", "world_nether"}, provider = LoadedWorlds.class)
private String spawnWorld = "world";

public final class LoadedWorlds implements OneOfSource {
    @Override public Collection<String> values() {
        return Bukkit.getWorlds().stream().map(World::getName).collect(toList());
    }
}

values() is called on EVERY evaluation, never cached — that is the point of a provider. The two sources are a union, so a static core and a dynamic tail can be declared together.

When the set is fixed at compile time, an enum is still the right answer. There the TYPE is the rule and @OneOf has no job. It exists for the legacy String you cannot migrate and the set that only exists at runtime.

@Explicit has an honest limit: seeding writes the default into the file, so after the first save the key exists and the rule is satisfied. It catches the first run — exactly when the warning matters — not forever.

The entity gets the last word

A @RuleReview method (or the RuleReviewer interface) runs after the engines and before the policy. It sees the violations on its own sites and decides them; it can also raise violations of its own logic.

Precedence is closed: review > engine > policy > Coercion.

public class DatabaseConfig implements RuleReviewer {

    @OneOf({"MONGO", "SQL"})
    private String dbType = "SQL";

    @Override
    public void reviewRules(RuleReviewContext review) {
        for (RuleViolation v : review.violations()) {
            // a magic word the annotation could never list, resolved at runtime
            if (v.rule() instanceof OneOf && "$auto".equalsIgnoreCase(String.valueOf(v.actualValue()))) {
                review.accept(v);
            }
        }
    }
}
public class MainConfig {

    private String databaseId = "";

    @RuleReview
    void databaseMustBeEnabled(RuleReviewContext review) {
        StorageConfig storage = StorageConfigs.current();   // another Config; wiring it is yours
        if (!storage.isEnabled(databaseId)) {
            review.fail("databaseId", "storage.yml declares '" + databaseId
                    + "' as disabled; pick an enabled database or enable it there");
        }
    }
}
Method Effect
accept(v) suppress it entirely — no issue, no log, no throw, even under a THROW policy
override(v, severity) re-stamp what it costs, outranking everything
correct(v, newValue) fix the site. Explicit author intent, so it does NOT need withCorrections(true)
report(path, message) raise a violation of the entity's own logic; the policy decides its cost
fail(path, message) the same, stamped THROW

Notes worth knowing:

  • It runs even with zero violations — the cross-config case does not depend on an annotation having fired — and in both phases (review.phase() tells them apart).
  • It is per owner: each instance reviews its own sites, the root does not see its descendants'. For the holistic view, @PostLoad(issues()) is still the place.
  • The last decision on a violation wins, so accept after override suppresses and override after accept resurrects.
  • It is invisible to introspection — a review is imperative code, and what a screen reads is the declared facts.
  • With RuleEngine.NONE, the whole subsystem — reviews included — is off.

Reading the rules without a Config

RuleModel resolves a type's rules with no Config, no ObjectMapper and no file. Each site carries the exact FILE path its value lands at (@Key/@JsonProperty/case transform/@Section applied), its @Comment lines and its default value — everything a generated settings screen needs.

for (RuleSite site : RuleModel.of(ServerConfig.class,
        RuleSelector.union(StandardRules.JAKARTA, StandardRules.EVERYCONFIG))) {
    if (site.rule() instanceof Max) {
        gui.addSlider(site.path(), ((Max) site.rule()).value(), site.defaultValue(), site.comment());
    }
}

The order is fully specified (field sites in declaration order, then type sites, then method sites; within a member, by annotation type name and declaration order), so a generated screen or report is reproducible.

Rule text as file comments

config.withRuleComments(true);

With that on, what each rule documents is folded into the comment at its path:

# Port the service binds to.
# At most 65535.
# At least 1.
port: 25565
  • Off by default — a config that asks for nothing keeps a byte-identical round trip.
  • The lines are composed into one write, never appended: an OVERRIDE @Comment is re-stamped on every save, and appending would grow the block forever.
  • A field carrying a @Comment is written under its mode; a field documented only by its rules is written set-if-absent, so library text never overwrites a hand-written comment.
  • describe() must be deterministic — text that varies would re-dirty the file on every save. That is why @OneOf renders only its declared set, never the provider's.

Writing your own rule

@ConfigRule is the whole SPI. Mark your annotation with it, point it at a handler, and it fires with no setup line at all — the built-in AnnotationRuleEngine is attached to every config from the start.

@ConfigRule(WorldExistsHandler.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WorldExists {
}
public final class WorldExistsHandler implements RuleHandler {

    @Override
    public void check(RuleContext context) {
        Object value = context.value();
        if (value != null && Bukkit.getWorld(value.toString()) == null) {
            context.report().violation(RuleViolation.of(context.site(), context.source(), value,
                    "myplugin.rule.worldexists", Collections.emptyList(),
                    "no world named '" + value + "' is loaded"));
        }
    }

    @Override
    public List<String> describe(RuleSite site) {
        return Collections.singletonList("Must be a loaded world.");
    }
}

A handler must be stateless and thread-safe with a no-argument constructor: one instance is created per handler class and shared by every config, concurrently. An idempotent internal cache (a compiled pattern) is fine; per-bind state is not.

To read a vocabulary that cannot carry @ConfigRule — someone else's annotations — implement RuleEngine with your own selector() and attach it. RuleEngine.compose(a, b) chains two by claim: a site the first selector claims is the first's, and the second only sees the rest.

Boundaries

Out of scope, by decision rather than omission:

  • A rule on an element of a collection or a Map (@OneOf on a List<String> judges the elements, but the site is still the field — there is no stable per-element path).
  • A rule on a top-level dynamic collection (readList / @KeyIndex) or on the dynamic path API.
  • A rule on a type persisted in the compact element form: one string, no sub-tree, so no site of it has a path to be judged or reported at. EveryConfig logs a one-time warning instead of silently skipping.
  • Full Bean Validation (groups, cascade, @Valid) and any provider. If you want it, attach an engine that delegates to your own Validator — about thirty lines, in your project.

→ See also Entity Binding, Annotations and Default Values & Comments

Clone this wiki locally