ADR-017 — Block Tags and Block-State Predicates in Safety Lists¶
Status: Accepted Date: 2026-04-19
Context¶
The unsafeBlocks and airBlocks lists in safety.yml wire as flat Set<String> of org.bukkit.Material.name() identifiers. SafetyKeys.unsafeBlocks/airBlocks are declared as List; ConfigParser coerces the YAML scalars to Set<String>; every pipeline caller (LocationGenerator, TeleportPipelineTask.isStillUnsafe, LinearAdjustor, JumpAdjustor, ScanTask, AnvilChunkView.isSafe) compares against the live block's Material.name().
Flat material names alone cannot express two admin intents:
- Block tags — Minecraft data-pack tag references such as
#minecraft:leaves,#minecraft:logs,#minecraft:wool,#rtp:hazardous. Without tag support, "all leaves are unsafe" requires enumerating every wood variant and re-editing on every Mojang release that adds a new wood type. - Block states — properties encoded on the
BlockData, not on theMaterial. The canonical example isWATERLOGGED=trueon fences, slabs, stairs, trapdoors, and signs;OAK_SLABis otherwise uniformly safe or uniformly unsafe with no way to distinguish "slab, but only when waterlogged". Other useful state predicates includeLEVEL(liquids),LIT(campfires),POWERED/ATTACHED(tripwire),AGE(crops, fire),OPEN(doors/trapdoors), andFACE=CEILING(buttons).
Two architectural constraints shape the solution:
rtp-apishall not take a compile-time dependency onorg.bukkit.*(ADR-011). Any solution that compiles a BukkitBlockDataat config-load time is ineligible to live inrtp-api./rtp config set/add/removeshall round-trip every configured value as a leaf string; nested YAML maps are not addressable by the command surface.
Decision¶
The safety-list grammar accepts three token shapes within the flat Set<String> wire format:
LAVA # bare material
#minecraft:leaves # tag reference (namespace required)
OAK_SLAB[waterlogged=true] # material + state predicate(s)
#minecraft:slabs[waterlogged=true] # tag + state predicate(s)
*[waterlogged=true] # wildcard material + state predicate(s)
The Set<String> wire type on RTPChunk.isSafe is preserved; an isSafe(int, int, int, CompiledUnsafeSet) overload carries the compiled form, and the legacy overload delegates through the compiler.
Edition note (full edition only). Everything beyond the bare-material token shape - block-tag references (
#namespace:path), state predicates (MATERIAL[...]), the wildcard (*[...]), and the numeric range predicates of the amendment below - is a full-edition feature. The rtp-lite assembly variant (ADR-024) parsesunsafeBlocks/airBlocksas a flat material allow/deny list and does not ship the tag resolver, thetags/snapshot,tagsRefresh.yml, or the predicate grammar. Lite tokens that contain a#prefix or a[...]body are treated as plain (and almost certainly unmatched) material names. This split is a packaging decision, not a grammar one: the parser andCompiledUnsafeSetare unconditionally present inrtp-api; lite simply does not wire the tag snapshot or advertise the grammar in its bundled docs.
1. Token grammar (normative)¶
token := material | tag | predicated
material := identifier | '*' # '*' is the wildcard
tag := '#' namespace ':' path # e.g. #minecraft:leaves
predicated := (material | tag) '[' pred (',' pred)* ']'
pred := propertyName '=' propertyValue
identifier := [A-Za-z0-9_]+
namespace := [a-z0-9_.-]+
path := [a-z0-9_/.-]+
- Bare materials remain UPPER_SNAKE to match
Material.name(). - Tag namespaces and paths are lowercase to match Mojang convention.
- A token such as
leaves(lowercase identifier, no#) is treated as a malformed material name and dropped at FINE by the existingPaletteNormalizerunknown-material path. propertyNameandpropertyValueare treated as case-insensitive lowercase strings. At evaluation time the live block's property is compared as the lowercaseStringproduced byBlockData.getAsString()parsing (Bukkit path) or the NBT palettePropertiescompound value (Anvil path). No typed resolution againstBlockData.getMaterial()is performed. Rationale: string-only comparison keeps the compiler pure and Bukkit-free, which is a prerequisite for placing it inrtp-apiand unit-testing it without a server.- Multiple predicates inside
[ … ]combine with logical AND. A candidate block matches the predicated token iff every listed property equals (case-insensitively) the live block's property of the same name. - Negation and disjunction are out of scope for this grammar. Numeric ranges were originally deferred here as well; see the Amendment (numeric range predicates) section below, which adds them in-grammar.
- Wildcard material. The identifier
*is reserved as a wildcard material that matches any block regardless ofMaterial.name(). A bare*(no predicate) is rejected at parse time with a WARNING — it would unsafe-mark every block in every world and is almost certainly a configuration mistake. Only predicated wildcards are meaningful:*[waterlogged=true]reads as "any block withwaterlogged=true", which is the canonical motivating example. Wildcards are never valid as the target of a tag reference (#*:foois nonsense and is rejected). - Wildcard hot-path cost. A wildcard predicated token forces every
candidate block to have its property map extracted. The compiler
records whether any wildcard token exists in a boolean flag
(
hasWildcardStatePredicate); when the flag isfalsethe fast path in §4 remains unchanged. Admins who configure a wildcard are opting into the cost.
2. Compiled form and module placement¶
PaletteNormalizer.reconcileAll(Set<String>) gains a compile step that
returns a CompiledUnsafeSet with four fields:
Set<String>— plain material names (fast path, unchanged).Set<String>— resolved tag keys (asnamespace:pathstrings; noNamespacedKeyimport — keepsrtp-apiBukkit-free).Map<String, List<StatePredicate>>— per-material state filters, keyed byMaterial.name().Map<String, List<StatePredicate>>— per-tag state filters, keyed bynamespace:path.List<StatePredicate>— wildcard state filters (applied to every candidate when non-empty).
A StatePredicate is an immutable Map<String, String> of
case-insensitive lowercase key/value pairs plus the original token for
diagnostics.
The compiled structure and grammar parser live in rtp-api. They
have no Bukkit dependency and no knowledge of which concrete materials
a tag expands to.
Tag expansion (namespace:path → Set<String> of material names) is a
separate concern and lives in the platform adapter:
rtp-bukkit-commonprovides aTagResolverthat delegates toBukkit.getTag(registry, key, Material.class)on plugin enable, snapshots the expansion into an immutableMap<String, Set<String>>, and hands the snapshot tortp-anvilfor off-tick use.rtp-paperandrtp-foliainherit the Spigot resolver; they overrideRTPChunk.isSafeonly if their nativeTag/BlockDatalookups diverge.rtp-anvilconsumes the snapshot via a setter invoked at plugin enable. The snapshot is read-only and thread-safe by construction.
3. Failure policy (REQ-RTP-S-004)¶
- Unknown tag at compile time: a single startup
WARNINGis logged naming the token; the tag is excluded from the compiled set; pipeline continues. - Unknown property name on a given material at compile time: a
single startup
WARNINGis logged; the predicate is dropped; remaining predicates on the token are retained. Rationale: fail-open on an unsafe list is the conservative choice — dropping a predicate can never cause an unsafe teleport (the Material-level rule still applies), whereas over-rejection blocks teleports. - Malformed token (unbalanced
[...], empty predicate, reserved character in value): single startupWARNINGwith the offending substring and offset; token is dropped. - Never silent: no parse failure returns without a log line. This is a REQ-RTP-S-004 surface; regression guard is
ReqRtpS004UnknownTagWarnTest(seeTRACEABILITY.md).
4. Evaluation (hot path)¶
RTPChunk.isSafe(int, int, int, CompiledUnsafeSet) proceeds in
ascending cost order:
- Compare the block's
Material.name()against the plain-material set. Hit → unsafe. Miss, the set has no tag or state entries applicable to this material, ANDhasWildcardStatePredicate == false→ safe without touchingBlockData. This fast path covers the overwhelmingly common case where an admin has only flat material names configured. - Look up the material's tag membership in the snapshot; if any tag has no state predicate → unsafe.
- If state predicates exist for the material or its tags, or
hasWildcardStatePredicate == true, fetchBlockDataonce, extract aMap<String, String>of lowercase properties, and AND-compare against each applicableStatePredicate(material-scoped, tag-scoped via tag-snapshot membership, and wildcard-scoped). Matching a property that the live block does not carry (e.g.waterlogged=trueon a block with nowaterloggedproperty) is a miss, not a match — per the fail-open policy the candidate is not marked unsafe by that predicate.
The Anvil path mirrors this: palette entries carry the same
Properties compound, read from NBT directly without allocating a
Bukkit BlockData.
5. Anvil pre-filter implication (ADR-016)¶
AnvilChunkView.isSafe shall read the palette entry's NBT Properties
compound when evaluating state predicates. An AnvilPrefilter verdict
of REJECT on a predicated match is legitimate; an ACCEPT remains
advisory per ADR-016 §3 and is re-checked live at teleport commit.
6. Configuration migration¶
No schema change. safety.yml remains a flat list. The file header
gains a §"Block tags and states" subsection with three worked examples
(bare material, tag, predicated material). Existing configs keep
working verbatim; legacyConfigSupport.yml emits no SUPERSEDED notice.
The /rtp config set/add/remove surface round-trips new tokens verbatim. Every token is a single string containing no YAML structural characters other than [, ], ,, and =, so the string-list serializer handles them; rtp config set safety unsafeBlocks add "OAK_SLAB[waterlogged=true]" round-trips through SubConfigCmd without quoting damage (covered by SubConfigCmd round-trip test).
Consequences¶
- Positive:
- Zero API break:
RTPChunk.isSafe(int, int, int, Set<String>)still compiles; addons usingSafetyKeys.unsafeBlocksdirectly are unaffected. - Admin edits stay in one flat YAML list;
/rtp configkeeps functioning on every token. - Grammar is additive: future tokens (e.g. biome predicates) fit the same
name[key=value]shape without a schema break. - Compiler is pure logic in
rtp-api— unit-testable without a server. -
Fast path in the hot evaluation loop means zero measurable cost for admins who only use plain material names.
-
Negative / Trade-offs:
- A mini-grammar to document and teach:
safety.ymlheader andGLOSSARY.mdboth carry entries soOAK_SLAB[waterlogged=true]is not opaque to new admins. AnvilChunkView.isSafeconsumes a second data source (NBTPropertiescompound), expanding the Anvil test matrix.- Tag-expansion snapshot introduces a one-shot cross-module hand-off (
rtp-bukkit-common→rtp-anvil) at enable time. A null snapshot degrades to "no tag match" (fail-open, logged once). - The
airBlockskey uses the same compiler; enabling the grammar there is a one-call wiring change. - Lowercase-string property comparison: the compiler lowercases both sides so
FACE=CEILINGmatches the liveface=ceilingproperty; documentation calls out the normalization.
Amendment (numeric range predicates)¶
Date: 2026-05-30 — closes the ROADMAP Tier 2 "Safety-list grammar expansion → numeric range predicates" item. The set-subtraction and hot-reload follow-ups from the same ROADMAP entry are explicitly not adopted.
The predicate grammar of §1 is extended so a pred may use a numeric comparison operator in addition to string equality:
pred := propertyName ( '=' propertyValue | compareOp integer )
compareOp := '>=' | '<=' | '>' | '<'
integer := '-'? [0-9]+
=retains its existing case-insensitive string-equality semantics; the value is unconstrained except for the reserved characters[,],=,<,>.- The four comparison operators parse the bound as a signed integer (
long) at config-load time. A non-integer bound is a malformed token (single startupWARNING, token dropped — §3, REQ-RTP-S-004), consistent with the existing malformed-token handling. - At evaluation time the live property value is parsed as a
long; an absent or non-numeric live value is a miss, not a match (the fail-open policy of §4 is preserved — a range predicate can never cause an over-rejection). - Multiple conditions in one
[ … ]still combine with logical AND, so two bounds on the same key express an interval (LAVA[level>=2,level<=5]). Duplicate detection is per(key, operator):level>=2,level<=5is accepted;level>=2,level>=3andwaterlogged=true,waterlogged=falseare rejected as duplicates.
Implementation: StatePredicate gains an immutable NumericComparison (key, Comparator operator, long bound) list alongside the existing equality map; SafetyTokenParser locates the operator and routes equalities vs. comparisons. No change to CompiledUnsafeSet bucket structure (range predicates ride inside the same per-material / per-tag / wildcard StatePredicate buckets), so the §4 hot-path fast-path and hasWildcardStatePredicate flag are unaffected. The Anvil path (§5) reads the same NBT Properties compound. This stays pure and Bukkit-free in rtp-api. Regression coverage: SafetyTokenParserTest, CompiledUnsafeSetTest.
References¶
docs/dev/REQUIREMENTS.md §3— REQ-RTP-S-001 (unsafe-block prohibition) and REQ-RTP-S-004 (never-silent failure).- ADR-011 — no Bukkit on the
rtp-apiclasspath. - ADR-016 — Anvil pre-filter advisory semantics; this ADR extends the pre-filter to consult palette
Properties. rtp-api/.../RTPChunk.java—isSafecontract.PaletteNormalizer/PaletteIdentifierNormalizerinrtp-api— grammar compiler.BukkitRTPChunk,FoliaRTPChunk,AnvilChunkView— live and off-tickisSafeimplementations.