hraness

versioning a language without breaking saved artifacts

old programs are data too

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

Languages version badly for a familiar reason: the artifacts outlive the implementations. A Python 2 program, an old Solidity contract, a Word document from 1997: the text survives while its meaning drifts. Platonik faces the same problem with higher stakes: its saved artifacts are evidence. If a language change silently altered what a committed receipt, a challenge case, a season result, or a saved habitat means, the archive would be rewritten retroactively, and every published comparison would quietly change its denominator. The solution in crates/platonik-core/src/model.rs is small and strict: the experiment carries its language version, the version gates every feature, and old versions keep their exact byte-level semantics forever.

old programs are data too

Start with the failure mode being prevented. Suppose the interpreter gained a new condition kind and the validation rules quietly began accepting it everywhere. A year-old receipt (schema platonik-receipt-v1, experiment and result hashed into fixtures/evidence/) would still verify: it doesn’t use the new feature, and re-execution reproduces the same result. So far, fine. But the suite of admissible v1 documents has changed, which means the claim “this program passed under platonik-habitat-v1” no longer picks out the same language. The artifact’s meaning drifted without a single byte changing.

Platonik closes this by making the version a semantic property, not a metadata hint. Experiment.version is a required field, protocol_for_version maps it to a protocol string (platonik-habitat-v1platonik-habitat-v4), and validate_experiment rejects any version it doesn’t know. The protocol string is stamped into the RunResult, so the output of a run names the language it was run under. A result is never only “passed”; it is “passed under v3,” which is a different, stronger, and permanently true statement.

the version field as gate

Every version-gated rule lives in validate_program and validate_experiment in sim.rs, and the pattern is uniform: features check experiment.version before they are even legal syntax in a submitted program.

Condition::AssemblyEdits { blueprint, count } => {
    experiment.version == VARIATION_VERSION
        && usize::from(*count) <= MAX_PROGRAM_EDITS
        && experiment.construction.as_ref().is_some_and(|spec| {
            spec.blueprints.iter().any(|entry| entry.id == *blueprint)
        })
}

An assembly_edits condition in a v3 document is not deprecated or warned; it is invalid, rejected at admission. The same gating applies to actions (gather_material, build, activate require v3+; edit_direction requires v4), to events (edge_blocked requires version >= HAZARD_VERSION), and even to numeric budgets: activation_fuel admits 1–1,024 under v1–v3 and up to MAX_VARIATION_ACTIVATION_FUEL = 16_384 under v4 only. The version field doesn’t annotate the language; it selects the language.

what each version added

Reading the four versions as a changelog is instructive, because each is a coherent capability, not a grab bag:

Version Protocol Added Frozen boundary
v1 platonik-habitat-v1 The base world: transport, signals, memory, drains MODEL_VERSION
v2 platonik-habitat-v2 edge_blocked events: declared route closures mid-run HAZARD_VERSION
v3 platonik-habitat-v3 Construction: gather_material, build, activate, has_material, assembly_stage, stocks, blueprints, assemblies CONSTRUCTION_VERSION
v4 platonik-habitat-v4 Variation: edit_direction, assembly_edits, MAX_PROGRAM_EDITS = 8, activation cap raised to 16,384 VARIATION_VERSION

Notice what each version didn’t do. v2 didn’t change movement; it added a declared intervention. v3 didn’t change execution; it added a way to create executors. v4 didn’t change the rule language’s structure; it added one primitive that rewrites a Move or Turn operand inside a copied body (an organism editing another organism’s program), with every edit recorded as a DirectionEdit carrying {tick, actor, rule, slot, value, before_hash, after_hash, bytes_written}. The edit chain is a versioned artifact inside the versioned artifact: derived_body in construction.rs replays it, re-validating and re-hashing the body after every step, so a modified program is never ambiguous about which bytes it is.

keeping old bytes true

The hard part of additive versioning is not adding; it is ensuring the addition doesn’t alter what existing documents say. Two mechanisms do that work here.

The first is canonical-byte preservation. When v2 introduced closed_edges to the runtime State, old v1 receipts would have changed serialization if the field always emitted. The code answers with a comment that is a manifesto: #[serde(default, skip_serializing_if = "Vec::is_empty")], “Omitted in legacy receipts so habitat-v1 bytes and identities stay unchanged.” New fields on shared types (edits on Assembly and Birth, copying and construction on Costs, material on CellState, construction on State and Experiment) are all skip_serializing_if-gated so that a v1 experiment serializes to exactly the bytes it always did: the hashes in committed v1 receipts remain the true hashes of the true documents, verifiable against the same strings that were published when v1 was the only version.

The second mechanism is rejection rather than reinterpretation. Every struct carries deny_unknown_fields, so a document written for a newer grammar fails cleanly in an older reader instead of being silently misread. Combined with the version gate, this makes the failure mode loud and early: a v4 experiment fed to a v1-era toolchain doesn’t produce subtly wrong behavior, it produces an error.

the rules that make it work

One detail rewards a second look: the envelope versioned separately from the payload. RECEIPT_SCHEMA is still platonik-receipt-v1: the receipt grammar never bumped, because {schema, protocol, experiment_hash, result_hash, experiment, result} works identically whether the inner experiment declares v1 or v4. The receipt’s protocol field carries the inner version, which means the envelope describes the payload’s language rather than absorbing it. Two artifacts can share a receipt schema and differ in every behavioral fact inside; the schema stability is honest because the grammar genuinely didn’t change.

The toolchain pin is part of the same contract. rust-toolchain.toml fixes Rust 1.97.1, the workspace pins edition 2024 and exact = versions of serde, serde_json, and sha2, and overflow-checks = true keeps release arithmetic honest. A versioned language whose semantics live in code needs the code’s own version pinned, or “v3 semantics” quietly means “whatever the current compiler produces.” The artifact’s language version and the interpreter’s build version are both inputs to the claim.

The challenge generator shows the same discipline at the format level. PUBLISHED_CHALLENGES = 64 defines a window of derivable ids, and the comment in challenge.rs is explicit: “Indices 1..=32 are the crossing family forever: their derived bytes are frozen by the committed artifacts.” The derivation stream mixes GENERATOR_VERSION into its seed (a generator change is a new derivation, not a mutation of the old one), and the published window “grows only with a reviewed generator change” that must not alter existing ids. Old challenges are data too.

Four transferable rules fall out of the design:

  1. Put the version inside the artifact, and gate semantics on it. A document that can’t say which language it is written in will eventually be read in the wrong one.
  2. New features are invalid in old versions, not ignored. deny_unknown_fields and the version checks make extension a hard error at admission, the cheapest possible failure. The field expedition shows the complementary rule on the input side: v2 edge hazards are opt-in: a saved v1 world keeps its original semantics and is never retrofitted with events it didn’t declare; new capability arrives as a new declared experiment, not a reinterpretation of an old save.
  3. Preserve canonical bytes for old documents. New fields must be skip_serializing_if-empty so a legacy value reserializes to its legacy bytes; otherwise every stored hash becomes a lie of omission.
  4. Record derived artifacts as chains, not snapshots. The v4 DirectionEdit list replays its own history with hashes: a derived program carries the proof of how it got there.

The underlying stance is that versioning is an obligation to the archive. The world engine can grow (new conditions, new actions, new caps), but each released version is a promise: any document that parsed as v1 parses as v1 forever, runs under v1 semantics forever, and hashes to its v1 identity forever. A language that keeps that promise can keep evidence; one that doesn’t is only a program that used to mean something.

sources

  • platonik: crates/platonik-core/src/model.rs for protocol_for_version, the version constants, and DirectionEdit; crates/platonik-core/src/sim.rs for version-gated validate_program/validate_experiment; crates/platonik-core/src/construction.rs for derived_body; crates/platonik-core/src/challenge.rs for the frozen challenge window
  • docs/rust-bridge.md: the implemented protocol surface
  • docs/construction-evaluation.md: v3 construction and its frozen predecessors
  • docs/challenges.md: the frozen challenge window and families