10. Versioning and Edition Strategy
10.1 The target is a compile-time parameter
Section titled “10.1 The target is a compile-time parameter”The target is the pair (edition, version), and neither is written in the source. Only the backend
knows them (Compilation Model).
Version strings are opaque labels. A Minecraft version may be the legacy semver-ish 1.21.4 or,
from the latest release onward, date-based. Cairn does not compare version strings; it orders by
DataVersion, the monotonically increasing integer Mojang assigns. That keeps since/until,
Vmin/Vmax, @requires, and semantic_sensitivity boundaries working across the semver → date-based
transition.
The backend holds a “version string ↔ DataVersion” table, so --target accepts either spelling of
the same version. Bedrock resolves its version strings to an internal monotonic key the same way.
10.2 Language contract: recompile, don’t transcode
Section titled “10.2 Language contract: recompile, don’t transcode”The spec does not guarantee NBT portability across version or edition. The only guarantee is “the result of compiling the same source to a given target”.
The source is the blueprint; the .nbt is a target-pinned build output, the equivalent of a binary.
To use a build on a new version or another edition, recompile the source.
DataFixerUpper is forward-only, lossy, and incomplete, and loss is common in items, signs, paintings, and block entities. It is a rescue tool, kept out of the language semantics.
Some residue is unsolvable and is stated rather than hidden:
- Meaning changes across versions (the cauldron split, item
tag→components). - Game behaviour not in data tables (fluids, gravity, attachment, redstone).
- Visual consistency (colour-temperature drift).
- Physics rule changes (1.21 wind charges breaking old traps).
Geometrically correct NBT is emitted; the gameplay experience is not guaranteed.
10.3 Backend = data tables
Section titled “10.3 Backend = data tables”Two sources feed the backend, and they are kept apart.
Machine-extracted from the game’s --reports / registry dumps. This is the truth about syntax
and domains: block and entity IDs, blockstate properties and their domains, item and component
schemas, DataVersion, tags. Taking the game itself as the source of truth, rather than anyone’s
memory, closes the knowledge gap about new versions at its root.
Hand-written, version-tagged constraint catalog for what the data does not carry: attachment (a frame cannot go on glass), gravity and support (gravel, hanging lanterns), fluid behaviour, entity AABB, redstone. Defined once per new version, and every user benefits.
constraints: minecraft:item_frame: type: entity_attachment since: "1.13" targets: { solid_full_face: true, glass_pane: false } error: "item_frame requires a solid attachable face" minecraft:lantern: type: support states: hanging=true: { requires_above: solid_or_chain } hanging=false: { requires_below: solid_top }Folding the (edition, version) matrix
Section titled “Folding the (edition, version) matrix”The canonical token is the primary key, and each token carries a per-edition mapping (id +
state_map). Versions fold with inherits + diffs: Java is the base, Bedrock the overriding diffs.
The hand-written semantic catalog records only the points that differ.
"@oak_stairs": base: { states: { half: [bottom,top], shape: [straight,inner_left,inner_right,outer_left,outer_right] } } mappings: java: { id: minecraft:oak_stairs, base: "1.13" } bedrock: { id: minecraft:oak_stairs, state_map: { half=top: {upside_down_bit: true} }, dropped_states: [shape] } sensitivity: - { edition: bedrock, kind: missing_state, state: shape, reason: "no inner/outer stair shape" }10.4 Fail-loud and minimum-version inference
Section titled “10.4 Fail-loud and minimum-version inference”Unknown IDs, out-of-domain states, and parity gaps are hard errors. Silent substitution and implicit dropping are forbidden. An error returns the closed set of candidates valid in the target, the minimum version, and a suggested fix. That sends the model back to registry-derived candidates rather than to its memory.
Out-of-domain states are not yet enforced: E_STATE_DOMAIN below is not implemented, because the
compiler holds no table of each block’s states. Until it does, a state literal
(Syntax) is written as given, and every one earns W_STATE_LITERAL_UNCHECKED
(Lint) instead.
E_UNKNOWN_ID line 12: "minecraft:pale_oak_planks" not in 1.21.4 registry. Similar valid: minecraft:oak_planks, minecraft:dark_oak_planks, minecraft:cherry_planks
E_VERSION_CAP line 7: minecraft:cherry_planks introduced in 1.20 (target 1.19.4). Fix: --target >=1.20, or slot decor -> @oak_planks
E_STATE_DOMAIN line 18: wall north=true invalid for 1.21.4. Valid: none, low, tall (changed from boolean in 1.16). Suggested DSL: wall_segment id=yard_wall connect_north=low
E_PARITY_UNSUPPORTED line 8: text_display is Java-only (since 1.19.4); Bedrock has no display entity. Suggested: sign side=front text="Inn", or slot+theme fallback, or @edition java guardWhich registry an unknown ID is unknown to
Section titled “Which registry an unknown ID is unknown to”An ID is checked against the block table for the one (edition, version) the compile pinned, not
against the edition as a whole. Bedrock 1.21.0 spells stone bricks stonebrick and 1.21.40 spells
it stone_bricks, so an edition-wide answer would accept both everywhere and catch neither mistake.
The tables ship in the registry pack’s blocks component, folded with the inherits + diffs rule
of §10.3.
The check therefore runs where a version is pinned and nowhere else: on cairn compile --target,
and on cairn check --edition E --target V, which pins the same pair to run the same pass without
writing anything. cairn info and cairn lower do lower, but pin no version, since info reports
across the whole range by design. They skip the comparison rather than pick a version on the
author’s behalf. A cairn check with no --target does not run block-array lowering at all, so no
lowering-stage code reaches it, E_UNKNOWN_ABSTRACT_TOKEN included.
Checking against every version the edition ships and refusing only the ids valid in none of them
would need no flag, and would answer a different question: stone_bricks is valid somewhere on
Bedrock, so a build pinned to 1.21.0 would still be told nothing. The pin is what makes the answer
true of the build being made.
The suggested fix has two halves, because a wrong ID arrives two ways. A typo is answered by a
distance search over the same table: oak_plank is answered with oak_planks. A rename is not
a typo — Bedrock spells Java’s light light_block_0 … light_block_15, eight edits away — and no
threshold that keeps the typo finder honest will ever connect the two. Renames are answered from
the registry pack’s aliases component instead, and only where the pack has a row; where it does
not, the message says it has no candidate rather than offering the nearest unrelated block.
An aliases row is a group of spellings: the names one block has worn, across editions and
across one edition’s own range, including the several IDs one old spelling split into.
{ "spellings": ["light", "light_block", "light_block_0", "light_block_1"] }Nothing in the row says which spelling belongs to which (edition, version). The blocks
component already knows that, per version, so a lookup is “take the group, keep the members the
pinned target declares” — which is what lets one set of rows answer Java → Bedrock
(oak_sign → standing_sign) and Bedrock 1.21.0 → 1.21.40 (stonebrick → stone_bricks) alike.
An answer is the closed set §10.4 asks for, never a pick from it: @light on Bedrock 1.21.60 is
answered with all sixteen light levels, because choosing one would be the silent substitution this
section forbids.
What the key cannot express is a spelling both editions declare meaning different blocks — Bedrock’s
snow is Java’s snow_block while Java’s snow is Bedrock’s snow_layer. Such a pair gets no
row, and the typo search still runs behind it.
The same per-version scoping applies to a pack’s own material mappings. An entry may carry
overrides naming the versions that spell it differently, which is what lets one
@floor.stone.smooth resolve across a range containing a rename. The since half is still
deferred: the tables record which IDs a version has, not which version first introduced one, so
the E_VERSION_CAP example above is an @requires floor rather than registry-inferred.
A part may declare its own floor
Section titled “A part may declare its own floor”def and theme may declare requires version>=X on a line of their own, and the minimum version
of a composite is the max of its parts:
def cottage size=9x7: requires version>=1.21.4 walls mat_slot=wall height=4The expression is the one @requires takes, edition scope and all — the two spellings differ in
what they constrain, not in what they say. A module-level @requires is a floor on the file; this
one is a floor on the part, so a place use=cottage inherits it and a library of templates
carries its own requirements instead of every consumer restating them.
Which parts a build inherits from. A def a place use= names, and a theme a scope binds. A
part nothing instantiates contributes nothing: a def no place names builds no voxels (and is
already W_UNUSED_DEF), so refusing a target over it would be refusing over a template the author
left in the file.
A theme’s floor applies when the theme is bound, whether or not a member reads a slot from it.
Binding a theme is the act of taking on what it declares. The alternative — charge the floor only
once one of its rules fires — makes the floor depend on which selectors matched and which variant
the pin picked, so one source could require 1.21 on Java and nothing on Bedrock for a reason that
is not about editions; and it errs in the unsafe direction, since an over-applied floor is reported
against the line that set it and is one edit away, while an under-applied one certifies a build the
file itself rules out.
Two things bind a theme, and both of them are a scope a build lowers: a place ... theme=NAME
reference — which is also what instantiates the def it places, since theme= is required on a
place — and the module-level auto-pick, read for a struct, the one scope a build lowers without
a placement. The auto-pick also binds the sole theme to every def scope, and a floor does not
follow it there: a def no place names builds nothing, so charging a theme’s floor because such a
def exists would read one def as instantiated enough to take on a theme’s floor and not
instantiated enough to be charged its own. A def that is placed reaches the theme through its
placement’s own theme= instead.
struct and site take no such line. Neither is instantiated by anything — each is the build —
so a floor written inside one constrains exactly the file it is in, which is what @requires
already says. The same goes for a member’s own indented children: the floor belongs to the part, and
a walls line is not a part. The two refusals are different messages, because the repairs differ:
one points at @requires, the other at a dedent. Neither refuses on the word alone: requires is
an ordinary keyword in a body that reads no floors, so a member line spelled that way parses in a
struct, a site, or under a member exactly as it did before this line existed.
A def or theme body is the other half of that, and takes the word whatever follows it: the line
is a floor, and an expression that reads as none is E_INVALID_REQUIRES rather than a member. That
costs nothing — requires has never been a member keyword, so the same line was E_UNKNOWN_KEYWORD
before.
E_VERSION_CAP names the part that imposed the floor, not only the number. A target refused by a
floor written inside a template is not actionable as a bare version, because the repair is at the
other end of the place use= that inherited it.
The declared floor is enforced
Section titled “The declared floor is enforced”A module’s floors compose by intersection: cairn compile --target is held to every @requires
line that applies to the build, and a target below any of them is E_VERSION_CAP, reported before
any artifact is prepared, so a refused build leaves no structure file and no lock. That ordering
matters: a lock records what was verified, and it must never say verified: true for a target the
source itself rules out.
The hint is weighed against the floor
Section titled “The hint is weighed against the floor”@intended_targets (§5.3) is a wish rather than a verification record, and the
floor above it is a constraint. A file may state both in a way that cannot hold — @requires version>=1.21 beside @intended_targets ["1.20.4"] — and before the floor was enforced that was
two inert statements. Now one of them decides a build and the other does not, which is the worst
arrangement: the header that reads like an instruction is the ignored one.
So each version the header names is placed in the target edition’s table and answered in one of three ways:
| The version | The finding |
|---|---|
No --target of the edition names it: a release the pack ships no block data for (1.19 on Java), or a label the table cannot place at all (1.21.40 on Java) |
W_INTENDED_TARGET_UNSUPPORTED |
| A version the edition builds, below a floor the file declares | E_INTENDED_TARGET_CAP when every buildable one is, W_INTENDED_TARGET_CAP when only some are |
| A version the edition builds, at or above every floor | Nothing |
The order is deliberate: a version the compiler cannot build is reported as that whatever the floors
say, because --target 1.19 not existing is what the author acts on and a cap beside it would ask
them to edit a floor that is not what stops the build. The split between the two cap codes is
reach rather than kind. Nothing the file says it is for can be built is the strongest reading a
contradiction between two declarations gets, and no author meant it; a list that reaches past the
floor at one end is a wish stated too widely, and what it names above the floor still builds.
“Every” counts the versions the edition can build. A name no --target of it carries answers
for none of the list, so @intended_targets ["1.20.4", "1.19"] under version>=1.21 is the first
case and not the second: Java builds exactly one of the two, and the floor refuses it. Counting
1.19 would let a version that was never buildable report a file nothing can build as half a
problem — and it is separately reported as the unsupported case, which is where its own repair is.
Which floors count is the composite fold §10.4 already
defines — the file’s @requires lines plus every part the build instantiates — so a def in a
library can refuse the intent of the file that places it, and the finding names the part. A floor
scoped to the other edition is inert here as everywhere, and a floor this edition’s table cannot
place refuses nothing: that is E_REQUIRES_UNORDERABLE, whose repair is on the requires line
rather than on the intent.
Every command that gates on cairn check reports the two cap codes — check, info, lower,
compile, synth — and each weighs the header in the tables of the editions it is about: the one
--edition names, the ones cairn info --editions lists, or both where the command names none. A
finding either edition reaches is reported, since the contradiction is between two lines of the file
however it is later built, and one span carries one cap finding: two editions disagreeing about how
far it reaches report the error, because one of them finding part of the list still buildable does
not make the other’s “none of it is” less true.
W_INTENDED_TARGET_UNSUPPORTED waits until exactly one edition is in scope, because a version Java
cannot build is routinely the Bedrock target the author means. cairn info --editions bedrock is
one edition in scope, and is weighed in Bedrock’s table alone — a report scoped to one edition is
not refused by the other’s answer.
E_REQUIRES_CONFLICT is reserved. It is defined as a declared floor contradicting the
registry-inferred range, and no inferred range is derived yet, because the pack carries no since
/ until. It is not a conflict between two @requires lines: floors compose by taking the
strictest, so their intersection is never empty. A constraint needing an upper bound, such as
version<1.20, is not a shape the language accepts; that is E_INVALID_REQUIRES.
Ordering is by DataVersion, per edition
Section titled “Ordering is by DataVersion, per edition”§10.1 makes DataVersion the canonical ordering key,
and @requires uses it. A floor is placed in the target edition’s version table
(registry-data/{java,bedrock}/data_versions.json) and weighed against the target’s own
DataVersion.
That table names every release of its edition, which is a different set from the versions the
pack can build for — three per edition, the ones it ships block and material data for. A row says
which it is (targetable). Keeping the two apart is what lets “inside the table’s span, naming no
row” mean “not a release of this edition”: a floor of 1.21.1 is a Java release the pack cannot
build for and can order perfectly well, while 1.21.4 names no Bedrock release at all, because
Bedrock numbers its patch releases in tens (1.21.0, 1.21.20, 1.21.40). The two editions’
release-label sets are disjoint.
A floor may still name something no table carries, so placing one is not a bare lookup. Four answers, and only the first is exact:
| The floor | Placed as | Because |
|---|---|---|
Names a row (trailing zeros ignored: 1.21 is Bedrock’s 1.21.0) |
That row’s DataVersion |
Exact. |
Names a pre-release of a row (1.21.4-rc1) |
That row’s DataVersion |
Nothing ships between a release candidate and its release, so no supported target lies between them either. |
| Sits below every row, or above every one | Met by every target, or by none | Reached by comparing the floor’s label against the first and last rows’ labels, while which rows those are is decided by their keys — so it holds exactly when the table’s labels sort the same way by text as by key. The registry pack loader checks that at load time. The floor’s own label must be a dotted decimal to be compared at all. |
| Anything else — inside the table’s span, naming no row | Not placed at all | It has no DataVersion, and there is none to give it. E_REQUIRES_UNORDERABLE. |
The last row is a refusal, not a guess. @requires version>=1.21.4 against Bedrock is exactly it:
Java’s release names no Bedrock release and sits between 1.21.0 and 1.21.20. Comparing the
labels read it as satisfied on 40 > 4 and certified a Bedrock build against a version below the
floor — the same defect enforcing the floor exists to remove, one edition to the left.
Because the label sets are disjoint, the refusal can say more than “no”. A label this edition
cannot place that the other edition names — a row of its table, or the pre-release of one — is a
floor written in the other’s numbering, and E_REQUIRES_UNORDERABLE names it and offers the scope.
A label the other edition does not name gets no scope offered, because recommending one would be
recommending a guess: scoped to an edition that does not name it either, the floor goes inert there
and the constraint disappears. That covers a label neither edition can place — a snapshot — and
also one the other edition places only below or above every row. Those two placements are
comparisons rather than releases: they say nothing about which numbering the author meant, and
scoped there the floor is met by every target of that edition or by none.
A floor may name its edition
Section titled “A floor may name its edition”Java releases run 1.20.4 / 1.21 / 1.21.4 and Bedrock 1.21.0 / 1.21.40 / 1.21.60. The two are
different scales, and a floor written in one of them means nothing in the other. So a floor may say
which it is written in:
@requires java version>=1.21.4@requires bedrock version>=1.21.40A scoped floor constrains its own edition’s build and is inert in the other’s — inert, not
violated, so the pair above builds on both. An unscoped floor is a floor on whatever is being
built, and is resolved in that edition’s table like any other. That makes @requires version>=1.21
a floor both editions can honour (Java’s 1.21, Bedrock’s 1.21.0), and makes a floor that names
one edition’s release and not the other’s the error above rather than a silent pass.
The registry compatibility row of cairn info (§10.5)
reads only the unscoped floors. It is one row for a file that may be reported against both editions
at once, and a floor in Java’s numbering says nothing about the file’s Bedrock range; the
per-edition answer is the buildable targets row.
A floor a theme declares is held to the same test, and for the part it is inherited through
rather than for the words on the line. Per-edition theme variants (§10.7)
mean the two editions can bind different themes for one theme= reference, so a theme feeds this
row only when both bind the same one — otherwise the floor is a per-edition fact wearing no edition
scope. A def needs no such test: a place use=NAME names one def and not a family of variants.
Whatever the row leaves out is named on stderr with the reason, so a 0.0 beside a buildable targets row that refuses versions is never left to be inferred.
Which labels a floor may use
Section titled “Which labels a floor may use”Every label shape §10.1 says will exist is accepted
by the directive: the semver-ish 1.21.4, the pre-release 1.21.4-rc1, a snapshot 24w14a, and
whatever a date-based scheme spells. The shape rule is dot-separated components that each begin
with a digit and carry only letters and digits, with an optional - and a pre-release tag of the
same. 1.a and x name no version in any scheme and are E_INVALID_REQUIRES.
Accepting a label is not claiming it can be ordered. Whether a given label has a DataVersion is
the table’s answer, and it is asked per edition: cairn compile --target refuses the build, and
cairn info --editions reports the edition as having no buildable target and says why. cairn check pins no edition and does not ask.
10.5 “Which version is it for?” has three answers
Section titled “10.5 “Which version is it for?” has three answers”There is no single “for-version”. cairn info reports three axes:
- Declared registry range
[Vmin, Vmax]: the floors the file declares without naming an edition, composed, against an open upper edge. A reading of what the source and the parts it instantiates declare, not a fact derived from the blocks they use — see theregistry compatibilityrow. - Semantic-sensitive members: cases where the ID stays valid but meaning, behaviour, or
appearance changes. This matters more than the range: behaviour changes far more often than IDs
disappear, so deciding Vmax from the registry alone is dangerous. The constraint catalog carries
a
semantic_sensitivity(boundary version + reason) separate fromsince/until, and a compile crossing one warns. Examples: the cauldron split at 1.17, wall connections going bool →none/low/tallat 1.16, the item format at 1.20.5. - The verified lock target (§10.6).
$ cairn info build.crn --editions java,bedrockregistry compatibility: 1.21.40 .. latestedition portability: Java: portable: 42 degraded: 0 unsupported: 0 Bedrock: portable: 38 degraded: 3 unsupported: 1buildable targets: Java: none (1.20.4, 1.21, 1.21.4 all refuse) Bedrock: 1.21.40, 1.21.60 (1.21.0 refuses)intended targets: 1.21.40, 1.21.60semantic-sensitive: yard_water(cauldron split@1.17), fence(wall conn@1.16)Every version named is one the built-in packs declare. The file behind this output carries
@requires version>=1.21.40, which is what puts every Java target below the floor, and Bedrock
1.21.0 with them.
intended targets is the file’s own @intended_targets line, verbatim, and the one row that is a
declaration rather than an answer. It sits beside buildable targets because that is the row it can
contradict — the comparison §10.4 automates for the half
of it that is decidable, and the reader makes for the rest. A file declaring none gets
(none declared) rather than a missing row.
The five lines go to stdout; what each figure is made of goes to stderr as note: lines. A pipeline
reading the rows sees the same five lines every time cairn info runs to completion. A run that
does not complete is a different case: a finding refuses the command before any row is computed, so
stdout is empty rather than short a line.
The registry compatibility row
Section titled “The registry compatibility row”The row reads declarations back; it never looks at the blocks the source uses. Vmin is the
strictest floor the file is bound by with no edition named — the @requires version>=X headers,
plus the requires version>=X line of every def and theme the build instantiates
(§10.4) — and 0.0 when none of them feed it. Working out
which parts a build instantiates is real work over the source; reading which blocks they paint is
not part of it.
“Strictest” is a comparison of the labels, so it is exact only while they are all dotted decimals.
A label the comparison cannot read as a number sorts above every one it can, which is fixed rather
than meaningful: a file declaring both version>=1.21.4 and version>=24w14a reports the snapshot.
That order decides no build — every gate that does weighs each floor against the target edition’s
table separately (§10.4).
The row is edition-agnostic, which is why it reads only the unscoped floors, for the reasons and
with the stderr notes §10.4 gives. So 0.0 has two causes — a
file that declares no floor, and one whose every floor names an edition — and the row cannot tell
them apart. The note on stderr can.
Vmax is the literal latest. An upper edge is the half of a derived range and this row carries
the declaration, so a pack that grows since / until would give its answer to buildable targets
rather than fill this in.
The row is a declaration, not an answer about which versions build. Those are easy to read as one thing, and a source can make them look identical. This one declares a floor every supported version clears, and uses a block Java gained in 1.21.4:
$ cat hut.crn@cairn 2026.06@requires version>=1.20.4
theme pale: slot floor -> @pale_moss_block slot wall -> @cobblestone
struct hut size=5x5 floor mat_slot=floor walls mat_slot=wall height=3
$ cairn info hut.crn --editions javaregistry compatibility: 1.20.4 .. latestedition portability: Java: portable: 2 degraded: 0 unsupported: 0buildable targets: Java: 1.21.4 (1.20.4, 1.21 refuse)intended targets: (none declared)semantic-sensitive: (none)1.20.4 .. latest reads as an answer, and the row two below disproves it — with 1.20.4 itself
in the refusal list. The declared floor is not wrong; it is answering a different question.
minecraft:pale_moss_block arriving in 1.21.4 is a fact about the pack, and this row reads only
what the file declares.
The derivation is the buildable targets row, two rows down. It weighs the source against every
supported version and reports which ones a build would accept, per edition — the answer an
intersection of since / until was meant to approximate, reached by asking rather than by
inferring. It is also the shape the intersection could not take: the answer is per edition, and it
need not be contiguous, so a [Vmin, Vmax] pair would have to claim a gap it cannot see.
The declared floor stays a row of its own because it is a different kind of fact: an input the
author wrote, which bounds what cairn compile --target accepts and what the file promises a
reader, where buildable targets is an output about the packs that happen to ship.
E_REQUIRES_CONFLICT (§10.4) is reserved for the
day the two can be compared — a declared floor contradicting a registry-inferred range — and
stays unreachable until a pack carries since / until.
The edition portability row
Section titled “The edition portability row”The row counts palette entries. An entry is unsupported for one of two reasons:
| Reason | The repair |
|---|---|
| The edition has no such block at all. | Change the material, or the pack’s mapping for it. |
| It has the block, but Cairn has no mapping for the states the intent carries (§10.7). | None yet. The mapping is Cairn’s to add. |
The first is a question about IDs and the second about states. Only the second can produce
degraded: a block that does not exist has nothing to lose detail from.
Because two different repairs hide behind one figure, each counted entry is named on stderr with
its reason. The ID case is answered the way E_UNKNOWN_ID answers one, and by the same two halves:
the aliases component where it has a row, so an entry this edition has under another name is
reported as that name rather than as a dead end (standing_sign on Java is oak_sign), and a
did you mean where it has none. They are alternatives, not a sequence — a row is the pack’s word
about which block this is, and printing a distance guess beside it would ask the reader to choose
between them. The alias question is asked of the edition here too: a spelling some supported
version declares is an answer, where a pinned build would keep only its own version’s.
--format json carries them as edition_portability[].unsupported_entries, one element per unit of
the count, in palette order.
Both questions are asked of the edition rather than of a version, because this row reports across
a whole compatible range. An ID valid for only part of that range is therefore not unsupported, as
when Bedrock renamed stonebrick to stone_bricks at 1.21.40. Whether the version being built has
it is what a pinned target answers, as E_UNKNOWN_ID — cairn compile --target, or cairn check --edition E --target V for the same answer without a build
(§10.4).
Which entries degraded, and what they lost
Section titled “Which entries degraded, and what they lost”degraded is the other figure over entries the command can name, and it is named the same way. The
case is weaker than unsupported’s: there is one reason an entry degrades and one repair for it, so
a reader is not choosing between repairs. What is identical is “which of the N” — roof-hip reports
degraded: 4 — and the only other place that is answered is the build, as W_INTENT_DEGRADED,
which is the run this command exists to be read before.
Each counted entry is named on stderr and carried in --format json as
edition_portability[].degraded_entries, one element per unit of the count, in palette order. An
entry is {id, states, dropped}:
| Field | Carries |
|---|---|
id |
The palette entry’s block ID, verbatim as the lowering interned it. |
states |
The entry’s key=value pairs, comma-joined, the same spelling the states_unmapped reason uses. |
dropped |
One {key, value} per intent the edition has no form for. key is a closed set (shape); a new kind of loss is a new key rather than a change to an existing one. |
Two lists rather than one under a category tag, and states rather than the ID alone. Degradation
is a fact about the state combination, not about the block: one ID reaches this list once per
combination that loses something, and roof-hip’s four entries are four spellings of
minecraft:spruce_stairs. A list keyed by the ID alone would print the same line four times.
dropped carries the property and the value rather than the sentence about them, for the reason the
unsupported reasons do: a consumer that reads this should not have to parse English to learn which
state was lost. A value the edition can express is not a loss and does not appear — Bedrock’s
stairs are straight, so shape=straight drops without an entry. The prose both the note and
W_INTENT_DEGRADED print is written in one place, and per key rather than over the pair, because
the sentence for a dropped shape talks about stairs: a second block family that drops an intent
brings its own key and its own sentence rather than inheriting this one.
A blockstate the pack should have refused is not a figure
Section titled “A blockstate the pack should have refused is not a figure”Two further failures can reach the state translator: a state value outside the Java domain
(facing=up on a stair), and a state key the translator does not read. Neither is an answer about
the edition. Both say that a blockstate no validated registry pack can produce reached the
translator anyway, which is a defect in the pack or in Cairn and not a property of the build being
reported on.
So they are not a third and fourth reason for unsupported. cairn info reports no portability
figures for an edition whose palette carries one: the counts would still be computable, and they
would read as ordinary portability — a leaked facing=up counted as unsupported: 1 is
indistinguishable from a stair whose corner shape Bedrock simply has no state for, which is the one
conclusion the reader must not draw. The command names every leaked entry on stderr with the
translator’s own message, says the repair belongs to the pack or to Cairn rather than to the source,
and exits non-zero without a row, the way any other finding that refuses the command does.
The state translator is the only place this can be observed, so the rule is stated for the states
question alone. It is not a licence to answer unsupported for a validation gap elsewhere: a figure
computed over a palette a validated pack could not have produced is not a portability answer,
whatever produced it.
The buildable targets row
Section titled “The buildable targets row”Counters cannot say everything: two entries can be declared by disjoint sets of versions and each answer “the edition has it”, leaving the row clean while no single version declares both.
buildable targets is the per-version answer. Per requested edition, it lists the supported
versions whose pinned lowering raises no error, with the refusing ones named beside them. It is a
set rather than a [Vmin, Vmax] range, because two IDs whose version sets interleave leave a gap a
range would claim.
It is derived by lowering once per supported version, the same check cairn compile --target runs.
It is not derived by intersecting the range-wide palette’s ID sets, which is unsound: with no
target pinned every material takes its default mapping, so a token the target respells is compared
as the wrong ID.
Like the counters, this row reports and does not refuse. cairn info exits 0 even for a source no
supported version can build, because the build is the command that refuses it. Each refusing
version’s own findings are printed under that version, so an E_UNKNOWN_ID never stands without the
target that raised it.
Why the list is empty
Section titled “Why the list is empty”An empty buildable has four causes, and they are not all repaired by the same edit: the
@requires line answers two of them, the member that produced no voxels a third, and whatever the
pinned lowering named the fourth. The list alone cannot tell them apart, so under --format json a
reason accompanies it. The key is absent whenever a version builds, so an ordinary report is
unchanged.
reason is an object, and every cause that holds is reported. Two of the four are facts about the
edition — identical under every release, and settled before any release is weighed — so they
are its own fields; the other two differ between releases and sit in a per-version list beside
them. Each field is omitted when it carries nothing.
| Field | Carries | What the author edits |
|---|---|---|
unplaceable_floors |
Floors | The @requires line. The floor names no release of this edition, so no version can be weighed against it and none is certified. |
dropped_scopes |
Scope keys, and site::SITE::FROM ↔ TO for a walkway |
The member or connect row that produced no voxels. It refuses every version before its ID table is consulted, since a partial build is not certified. |
versions |
Refused targets | One entry per version that refused for a reason of its own. |
Beside rather than instead: a file can declare a floor this edition cannot place and use an ID one of its releases has never had. A shape that reported the first alone would send the author back for the second after the repair, one cause per run, which is the loop this row exists to close.
versions is a subsequence of considered, not a parallel array — a release with nothing
against it but an edition-wide answer above contributes no entry — so a consumer joins on version
rather than by position. Each entry carries its version and a refusal tag:
refusal |
Carries | What the author edits |
|---|---|---|
below_floor |
floors |
The @requires line. Only the floors that refuse this version: a file declaring several is a file where the repair is one line rather than all of them. |
lowering_refused |
findings |
Whatever the findings name, usually a material or an ID, and it differs per version. |
The two are exclusive, because a version below a floor is never lowered: a floor is a relation between the source and the target and no ID table changes it.
A floor is {declared, line, col, declared_by?}: the floor as the author wrote it, scope and all,
and where it is written. declared_by is {keyword, name} with keyword one of def or theme,
for a floor a build inherited from a part; it is absent for a floor on the file itself — the
position already points at the line, and the line is the file’s.
findings are rendered the way spec/lint “Machine-readable payload” renders a finding, and are
the same findings the run prints under that version on stderr. That section is unchanged by this
row: these ride inside the report rather than in the {"diagnostics": [ ... ]} document, and a
report is not a refusal.
The text rows say none of this. buildable targets: Bedrock: none (1.21.0, 1.21.40, 1.21.60 all refuse) is true for every one of the four causes. Each cause is reported on stderr as well, though
not in the same shape: the two floor causes print a note: carrying the position of the line, a
dropped scope prints a note: naming the scope and no position, and a refused lowering prints the
findings themselves under the version, each with its own position. It is the JSON that could not be
read.
A fifth line, recommended test targets, belongs to this axis and answers a different question
again: which versions are worth testing against. No code path emits it yet.
10.6 Provenance and lock
Section titled “10.6 Provenance and lock”The .crn carries only @intended_targets, a hint. verified: true, the DataVersion, and the
hashes exist only in the lock, written by the compiler on a successful build. They are never
hand-written.
# build.cairn.lock (compiler-generated)lock_schema_version: 1 # revision of this document's own schemasource_hash: sha256:...cairn_version: 2026.06 # the Cairn release's date version (CalVer)target: { edition: java, mc_version: 1.20.4, data_version: 3700 }inputs: { registry_pack_hash: sha256:..., constraint_catalog_hash: sha256:... }resolved_ir_hash: sha256:...verified: truemember_version_sensitivity: [ { id: yard_water, reason: "cauldron split at 1.17" } ]resolved_ir_hash is the core of reproducibility: it fixes the IR after macro expansion, default
filling, and auto-address assignment.
lock_schema_version leads the document so a reader can decide whether it understands the rest
before parsing it. Version 1 is the shape above, and a document omitting the key is version 1.
A document declaring a higher version is refused rather than read as if the field names still meant
the same thing. Keys the schema does not declare are refused wherever they appear.
Recompiling for a different target shows the difference from the verified one loudly:
$ cairn compile build.crn --target 1.21.4 --lock build.cairn.lockW_PREVIOUSLY_VERIFIED_TARGET: verified for 1.20.4/DataVersion 3700, now 1.21.4/4189.W_SEMANTIC_SENSITIVITY: 2 members may resolve differently: yard_water, fence10.7 Java / Bedrock portability
Section titled “10.7 Java / Bedrock portability”Derivation rules are edition-specific: intent_state is neutral, resolved_state is
per-edition. The contract is “from the same intent, resolve the nearest legal representation per
edition”, not “guarantee the same result”.
intent_state: { primitive: stairs, corner: inner_left, facing: east } # edition-neutralresolved_state: java: { facing: east, half: bottom, shape: inner_left } bedrock: { weirdo_direction: 1, upside_down_bit: false } # no shape → corners don't joinWhen a resolved difference becomes a visual or functional one, lint says so:
W_INTENT_DEGRADED line 12 id=roof_corner: shape=inner_left cannot be resolved in Bedrock (stairs have no shape state). Bedrock stairs render straight; visual gaps at corners.The canonical vocabulary absorbs only ID, state, and serialization differences. Concept absence and game-behaviour differences are not absorbed. The cases that cannot be:
- display entities (absent on Bedrock)
- stairs
shape(no such state on Bedrock) - armor_stand pose
- redstone propagation
- item components ↔ Bedrock item NBT
- light block internal behaviour
Writing an alternative
Section titled “Writing an alternative”@edition conditionals in the semantic layer are forbidden. When an alternative is needed, work
down this hierarchy:
- Use a closed semantic primitive (neutral). If it is not representable, fail loud with
E_PARITY_UNSUPPORTED. - Fall back via slot + per-edition theme, resolving a
floating_textslot totext_displayon Java and a glowing sign on Bedrock. - Only at the escape-hatch layer, guard with
@edition. Raw IDs and NBT are edition-specific by nature.
@requires bedrock version>=1.21.40 # the Bedrock branch below is spelled for the flattened ids
hologram id=shop_sign text="Weapon" mat_slot=floating_text # the semantic layer is always neutraltheme shop_java: slot floating_text -> text_display scale=2.0theme shop_bedrock: slot floating_text -> sign glowing=true # Bedrock fallback
@edition java { raw_block mat=minecraft:light[level=15] at=4,3,2 }@edition bedrock { raw_block mat=minecraft:light_block_15 at=4,3,2 }@edition pins an edition, not a version
Section titled “@edition pins an edition, not a version”The guard settles which branch is built and nothing else. The id inside it is still checked against
the one (edition, version) the compile pinned
(§10.4), so where an edition respells a block
inside its own supported range, @edition bedrock names the branch and the file’s floor names the
spelling.
Bedrock’s light block is that case, and it is why the snippet above carries a floor. 1.21.0 spells
the block light_block and carries the level beside it as a block_light_level state; 1.21.40
promoted the level into the id, so 1.21.40 and 1.21.60 spell the same block light_block_0 …
light_block_15 and have no id named light_block at all. The aliases row holding all of those
spellings together (§10.4) answers the diagnostic
and not the source. Its answer is the closed set the pinned target declares — light against
Bedrock 1.21.60 comes back as all sixteen levels — and a set of sixteen is not a spelling: picking
one of them is the silent substitution that section forbids, so it stays the author’s to do.
Writing the branch for one of the two shapes is doing it, and the floor is what says which shape
that is. Being scoped, it is inert on the Java build (§10.4),
which the other branch serves.
Leaving the floor off is loud rather than wrong: light_block_15 against Bedrock 1.21.0 is
E_UNKNOWN_ID, since the check is per version. What the floor adds is not a different refusal but
a declaration — the version half of what the branch is for, written where the rest of the file’s
constraints are, and the half the other headers can be read against. A file floored at 1.21.40
whose @intended_targets names 1.21.0 is E_INTENDED_TARGET_CAP at cairn check
(§10.4), before any target is picked; the same file
without the floor says nothing until one is. A build that must serve both spellings is two builds:
there is no version conditional to pair with @edition.
The build picks the variant, not the source
Section titled “The build picks the variant, not the source”theme NAME_java and theme NAME_bedrock declare two variants of the logical theme NAME. A
--edition pin binds that edition’s variant, falls back to an unsuffixed NAME, and stops there.
Binding the other edition’s variant would route its slot values into this edition’s output, which
is the silent substitution §10.4 forbids. When
neither exists the compile stops with E_THEME_VARIANT_MISSING rather than building the requested
extent out of air.
place ... theme=NAME names the logical theme and follows exactly that rule, so one site places
the same def under whichever variant the build needs. Naming a variant there
(theme=shop_bedrock) still resolves: the pin binds the variant it selects, and
W_THEME_VARIANT_REBOUND says which was bound instead. The neutral spelling is still what the
semantic layer is meant to carry.
With no --edition pin, nothing re-picks a variant the author named. A declared name binds
verbatim. A name written without a suffix resolves through the same unpinned order the
module-level pick uses. A name written with a suffix the module does not declare is
E_UNRESOLVED_THEME_REF.
Cross-version application
Section titled “Cross-version application”Asymmetric by design:
- Downgrade (new-version NBT → old-version world) is a hard error. Unknown components cause crashes and corruption.
- Upgrade (old-version NBT → new-version world) is a loud warning plus a DataVersion stamp, and
depends on DFU. It requires an explicit
--allow-cross-version.
Not every build needs to be edition-portable. The compiler’s job is to state what breaks portability.
10.8 Compatibility tier of Cairn’s own surfaces
Section titled “10.8 Compatibility tier of Cairn’s own surfaces”The (edition, version) axis above covers what Cairn emits. The orthogonal axis is what Cairn
promises about its own evolution: .crn syntax, the lockfile, the CLI flags, the Rust API. CalVer
has no “major” axis to read those promises off, so they are spelled out in
Compatibility Tiers. A Stable surface gives one release of W_DEPRECATED lead
time; an Evolving surface can change in any monthly minor; Internal makes no promise.