> =
+ java.util.Map.copyOf(
+ values
+ .filterValues { it.isNotEmpty() }
+ .mapValues { (_, aliases) -> java.util.Set.copyOf(aliases) },
+ )
+ }
+}
diff --git a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
new file mode 100644
index 00000000..91c8e359
--- /dev/null
+++ b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.metamodel;
+
+import com.embabel.agent.core.Cardinality;
+import com.embabel.agent.core.DataDictionary;
+import com.embabel.agent.core.DomainType;
+import com.embabel.agent.core.DynamicType;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Calls every entry point the way a Java consumer compiled against the previous release does.
+ *
+ * Aliases and provenance were added as trailing parameters with defaults, so a Kotlin caller sees
+ * no change. A Java caller sees whatever descriptors the compiler emitted, which is why
+ * {@code @JvmOverloads} is on those constructors and factories: without it, adding a parameter
+ * would delete the descriptor an already-compiled consumer is linked against, and the failure
+ * would be a {@code NoSuchMethodError} at runtime rather than a compile error here.
+ */
+class MetamodelJavaCompatTest {
+
+ /** DynamicType has no Java-friendly overloads upstream, so every argument is spelled out. */
+ private static DomainType type(String name) {
+ return new DynamicType(name, "", List.of(), List.of(), true);
+ }
+
+ private static DataDictionary goldenSchema() {
+ return DataDictionary.fromDomainTypes("golden-schema", List.of(type("Person"), type("Company")));
+ }
+
+ @Test
+ void theFourArgumentPropertySignatureConstructorStillExists() {
+ PropertySignature signature = new PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE);
+
+ assertEquals("age", signature.getName());
+ assertEquals(Set.of(), signature.getAliases());
+ }
+
+ @Test
+ void thePropertySignatureConstructorAlsoTakesAliases() {
+ PropertySignature signature = new PropertySignature(
+ "emailAddress", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, Set.of("email"));
+
+ assertEquals(Set.of("email"), signature.getAliases());
+ }
+
+ @Test
+ void theFiveArgumentMetamodelVersionConstructorStillExists() {
+ MetamodelVersion version = new MetamodelVersion(
+ "test",
+ List.of("Person"),
+ Map.of("Person", Set.of("Person")),
+ Map.of("Person", Set.of()),
+ List.of());
+
+ assertEquals(List.of("Person"), version.getEntityTypeNames());
+ assertEquals(Map.of(), version.getEntityTypeAliases());
+ assertNull(version.getOrigin());
+ assertNull(version.getLastStamped());
+ }
+
+ @Test
+ void theMetamodelVersionConstructorAlsoTakesAliasesAndProvenance() {
+ MetamodelVersion version = new MetamodelVersion(
+ "test",
+ List.of("Person"),
+ Map.of("Person", Set.of("Person")),
+ Map.of("Person", Set.of()),
+ List.of(),
+ Map.of("Person", Set.of("Human")),
+ new StampProvenance("deploy-pipeline", "release-1"),
+ new StampProvenance("operator", null));
+
+ assertEquals(Set.of("Human"), version.getEntityTypeAliases().get("Person"));
+ assertEquals("deploy-pipeline", version.getOrigin().getActor());
+ assertEquals("operator", version.getLastStamped().getActor());
+ assertNull(version.getLastStamped().getTrigger());
+ }
+
+ @Test
+ void theOneAndTwoArgumentStampingFactoriesStillExist() {
+ MetamodelVersion whole = MetamodelVersion.from(goldenSchema());
+ MetamodelVersion governed = MetamodelVersion.from(goldenSchema(), GovernedTypeSelector.ALL);
+
+ assertEquals(whole.getContentHash(), governed.getContentHash());
+ assertEquals(List.of("Company", "Person"), whole.getEntityTypeNames());
+ }
+
+ @Test
+ void theStampingFactoryAlsoTakesAliases() {
+ MetamodelVersion version = MetamodelVersion.from(
+ goldenSchema(),
+ GovernedTypeSelector.ALL,
+ new SchemaAliases(Map.of("Person", Set.of("Human")), Map.of()));
+
+ assertEquals(Set.of("Human"), version.getEntityTypeAliases().get("Person"));
+ }
+
+ @Test
+ void theOneAndTwoArgumentDeclarationFactoriesStillExist() {
+ DeclaredSchema whole = DeclaredSchema.from(goldenSchema());
+ DeclaredSchema governed = DeclaredSchema.from(goldenSchema(), GovernedTypeSelector.ALL);
+
+ assertEquals(whole, governed);
+ assertEquals(Set.of(), whole.getRelationshipTypeNames());
+ }
+
+ @Test
+ void theDeclarationFactoryAlsoTakesAliases() {
+ DeclaredSchema declared = DeclaredSchema.from(
+ goldenSchema(),
+ GovernedTypeSelector.ALL,
+ new SchemaAliases(Map.of("Person", Set.of("Human")), Map.of()));
+
+ assertEquals(Set.of("Human"), declared.getVersion().getEntityTypeAliases().get("Person"));
+ }
+
+ @Test
+ void theNoArgumentAliasAndProvenanceConstructorsExist() {
+ assertEquals(Map.of(), new SchemaAliases().getTypeAliases());
+ assertEquals(Map.of(), SchemaAliases.NONE.getPropertyAliases());
+ assertNull(new StampProvenance().getActor());
+ assertEquals("ci", new StampProvenance("ci").getActor());
+ }
+
+ @Test
+ void theCollectionsAStampHandsBackRefuseMutationFromJava() {
+ MetamodelVersion version = MetamodelVersion.from(
+ goldenSchema(),
+ GovernedTypeSelector.ALL,
+ new SchemaAliases(Map.of("Person", Set.of("Human")), Map.of()));
+
+ assertTrue(throwsOnMutation(() -> version.getEntityTypeAliases().remove("Person")));
+ assertTrue(throwsOnMutation(() -> version.getEntityTypeAliases().get("Person").add("Sneaky")));
+ }
+
+ @Test
+ void theShippedKotlinDefaultSyntheticKeepsItsDescriptor() throws Exception {
+ // A Kotlin caller that omits a defaulted argument links against the $default synthetic
+ // rather than the function itself. DeclaredSchema.from shipped with one defaulted
+ // parameter, so that synthetic is part of the module's binary surface. Adding a third
+ // defaulted parameter would have rewritten its descriptor and turned every already
+ // compiled `DeclaredSchema.from(dictionary)` into a NoSuchMethodError, which is why
+ // SchemaAliases arrives on a separate overload that requires it.
+ Class> companion = Class.forName("com.embabel.dice.metamodel.DeclaredSchema$Companion");
+
+ assertNotNull(companion.getDeclaredMethod(
+ "from$default",
+ companion,
+ DataDictionary.class,
+ GovernedTypeSelector.class,
+ int.class,
+ Object.class));
+ }
+
+ @Test
+ void theStampingFactoriesTakeNoDefaultedParameters() throws Exception {
+ // MetamodelVersion.from shipped as two overloads with no defaults, so it has no $default
+ // synthetic to preserve. Keeping it that way means the alias overload can never widen one.
+ Class> companion = Class.forName("com.embabel.dice.metamodel.MetamodelVersion$Companion");
+
+ long defaultSynthetics = Arrays.stream(companion.getDeclaredMethods())
+ .filter(method -> method.getName().equals("from$default"))
+ .count();
+
+ assertEquals(0, defaultSynthetics);
+ }
+
+ private static boolean throwsOnMutation(Runnable mutation) {
+ try {
+ mutation.run();
+ return false;
+ } catch (UnsupportedOperationException expected) {
+ return true;
+ }
+ }
+}
diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt
index b745d2e0..0adf3ecc 100644
--- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt
+++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/DeclaredSchemaTest.kt
@@ -18,8 +18,10 @@ package com.embabel.dice.metamodel
import com.embabel.agent.core.DataDictionary
import com.embabel.agent.core.DomainTypePropertyDefinition
import com.embabel.agent.core.DynamicType
+import com.embabel.agent.core.ValuePropertyDefinition
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
class DeclaredSchemaTest {
@@ -61,4 +63,65 @@ class DeclaredSchemaTest {
assertEquals(DeclaredSchema.from(dictionary()), source.declare())
}
+
+ @Test
+ fun `declaring no aliases matches declaring none explicitly`() {
+ assertEquals(
+ DeclaredSchema.from(dictionary()).version.contentHash,
+ DeclaredSchema.from(dictionary(), GovernedTypeSelector.ALL, SchemaAliases.NONE).version.contentHash,
+ )
+ }
+
+ @Test
+ fun `declared aliases reach the stamp`() {
+ val declared = DeclaredSchema.from(
+ dictionary(),
+ GovernedTypeSelector.ALL,
+ SchemaAliases(
+ typeAliases = mapOf("Person" to setOf("Human")),
+ propertyAliases = mapOf("Person" to mapOf("worksAt" to setOf("employer"))),
+ ),
+ )
+
+ assertEquals(setOf("Human"), declared.version.entityTypeAliases["Person"])
+ assertEquals(
+ setOf("employer"),
+ declared.version.entityTypeProperties["Person"]!!.single { it.name == "worksAt" }.aliases,
+ )
+ assertNotEquals(DeclaredSchema.from(dictionary()).version.contentHash, declared.version.contentHash)
+ }
+
+ @Test
+ fun `a type alias naming another declared type is refused`() {
+ val thrown = assertThrows {
+ DeclaredSchema.from(
+ dictionary(),
+ GovernedTypeSelector.ALL,
+ SchemaAliases(typeAliases = mapOf("Person" to setOf("Company"))),
+ )
+ }
+ assertTrue(thrown.message!!.contains("Company"), thrown.message)
+ }
+
+ @Test
+ fun `aliases on a property name the merge holds two signatures for are refused`() {
+ // Two same-named Person declarations each carry their own `age`, so the union holds two
+ // signatures and an old name can't say which of them it meant.
+ val duplicated = DataDictionary.fromDomainTypes(
+ "app",
+ listOf(
+ DynamicType(name = "Person", ownProperties = listOf(ValuePropertyDefinition("age", type = "string"))),
+ DynamicType(name = "Person", ownProperties = listOf(ValuePropertyDefinition("age", type = "integer"))),
+ ),
+ )
+
+ val thrown = assertThrows {
+ DeclaredSchema.from(
+ duplicated,
+ GovernedTypeSelector.ALL,
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("age" to setOf("years")))),
+ )
+ }
+ assertTrue(thrown.message!!.contains("years"), thrown.message)
+ }
}
diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
index 195a042d..bc68a68e 100644
--- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
+++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
@@ -172,6 +172,51 @@ class MetamodelVersionTest {
MetamodelVersion.from(renamed).contentHash,
)
}
+
+ @Test
+ fun `an alias-free declaration still hashes to the digest pinned before aliases existed`() {
+ // The literal below was produced by the encoding as it stood before PropertySignature
+ // carried aliases and MetamodelVersion carried entityTypeAliases. Alias blocks are
+ // written only when they hold something, so a schema declaring no former names has to
+ // render the same bytes and keep every hash already recorded against it. All four ways
+ // of saying "no aliases" have to land on it.
+ val pinned = "0a5b5b62c125d8ade5bcd2af5b03e0ec5bcaaf5b0799b7cfe8c16be6e723de00"
+
+ assertEquals(pinned, MetamodelVersion.from(goldenSchema()).contentHash)
+ assertEquals(pinned, MetamodelVersion.from(goldenSchema(), GovernedTypeSelector.ALL).contentHash)
+ assertEquals(
+ pinned,
+ MetamodelVersion.from(goldenSchema(), GovernedTypeSelector.ALL, SchemaAliases.NONE).contentHash,
+ )
+ assertEquals(
+ pinned,
+ MetamodelVersion.from(
+ goldenSchema(),
+ GovernedTypeSelector.ALL,
+ SchemaAliases(typeAliases = emptyMap(), propertyAliases = emptyMap()),
+ ).contentHash,
+ )
+ }
+
+ @Test
+ fun `rebuilding the golden stamp through the public constructor hashes to the same literal`() {
+ // The storage mapper reconstructs a stamp field by field rather than from a dictionary.
+ // Passing an explicitly empty alias map and no provenance has to reproduce the pinned
+ // digest, or a row written before aliases existed could never be read back.
+ val fromDictionary = MetamodelVersion.from(goldenSchema())
+ val rebuilt = MetamodelVersion(
+ schemaName = fromDictionary.schemaName,
+ entityTypeNames = fromDictionary.entityTypeNames,
+ entityTypeLabels = fromDictionary.entityTypeLabels,
+ entityTypeProperties = fromDictionary.entityTypeProperties,
+ relationshipNames = fromDictionary.relationshipNames,
+ entityTypeAliases = emptyMap(),
+ )
+ assertEquals(
+ "0a5b5b62c125d8ade5bcd2af5b03e0ec5bcaaf5b0799b7cfe8c16be6e723de00",
+ rebuilt.contentHash,
+ )
+ }
}
@Nested
@@ -808,4 +853,666 @@ class MetamodelVersionTest {
assertEquals("my-schema", version.schemaName)
}
}
+
+ @Nested
+ inner class Aliases {
+
+ private fun personWith(vararg properties: PropertyDefinition): DataDictionary =
+ DataDictionary.fromDomainTypes(
+ "test",
+ listOf(DynamicType(name = "Person", ownProperties = properties.toList())),
+ )
+
+ private fun stamp(dictionary: DataDictionary, aliases: SchemaAliases): MetamodelVersion =
+ MetamodelVersion.from(dictionary, GovernedTypeSelector.ALL, aliases)
+
+ @Test
+ fun `a declared property alias lands on the signature`() {
+ val version = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email")))),
+ )
+
+ assertEquals(
+ setOf(
+ PropertySignature(
+ "emailAddress",
+ PropertySignature.Kind.VALUE,
+ "string",
+ Cardinality.ONE,
+ setOf("email"),
+ ),
+ ),
+ version.entityTypeProperties["Person"],
+ )
+ }
+
+ @Test
+ fun `declaring a property alias changes the hash`() {
+ val plain = stamp(personWith(ValuePropertyDefinition("emailAddress")), SchemaAliases.NONE)
+ val aliased = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email")))),
+ )
+
+ assertNotEquals(plain.contentHash, aliased.contentHash)
+ assertFalse(plain.hasSameContentAs(aliased))
+ }
+
+ @Test
+ fun `the order aliases are declared in does not affect the hash`() {
+ val forwards = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to linkedSetOf("email", "contact"))),
+ ),
+ )
+ val backwards = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to linkedSetOf("contact", "email"))),
+ ),
+ )
+
+ assertEquals(forwards.contentHash, backwards.contentHash)
+ }
+
+ @Test
+ fun `different alias sets on one property hash differently`() {
+ val one = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email")))),
+ )
+ val two = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email", "contact")))),
+ )
+
+ assertNotEquals(one.contentHash, two.contentHash)
+ }
+
+ @Test
+ fun `an alias containing the block delimiter does not collide with a split set`() {
+ // Same reasoning as the property-name case: alias entries are length-prefixed, so
+ // ["a;b"] and ["a", "b"] can't serialise to the same bytes.
+ val joined = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("a;b")))),
+ )
+ val split = stamp(
+ personWith(ValuePropertyDefinition("emailAddress")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("a", "b")))),
+ )
+
+ assertNotEquals(joined.contentHash, split.contentHash)
+ }
+
+ @Test
+ fun `an alias for a property the type doesn't have changes nothing`() {
+ val plain = stamp(personWith(ValuePropertyDefinition("age")), SchemaAliases.NONE)
+ val stale = stamp(
+ personWith(ValuePropertyDefinition("age")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("retired" to setOf("gone")))),
+ )
+
+ assertEquals(plain.contentHash, stale.contentHash)
+ }
+
+ @Test
+ fun `an alias equal to the property's own name is kept and hashed`() {
+ // It matches nothing at diff time — nothing looks data up by property name — so it is
+ // inert there. It is still part of the signature, so it moves the hash.
+ val plain = stamp(personWith(ValuePropertyDefinition("age")), SchemaAliases.NONE)
+ val selfAliased = stamp(
+ personWith(ValuePropertyDefinition("age")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("age" to setOf("age")))),
+ )
+
+ assertEquals(setOf("age"), selfAliased.entityTypeProperties["Person"]!!.single().aliases)
+ assertNotEquals(plain.contentHash, selfAliased.contentHash)
+ }
+
+ @Test
+ fun `a declared type alias is carried and changes the hash`() {
+ val plain = stamp(personWith(), SchemaAliases.NONE)
+ val aliased = stamp(personWith(), SchemaAliases(typeAliases = mapOf("Person" to setOf("Human"))))
+
+ assertEquals(mapOf("Person" to setOf("Human")), aliased.entityTypeAliases)
+ assertEquals(emptyMap>(), plain.entityTypeAliases)
+ assertNotEquals(plain.contentHash, aliased.contentHash)
+ }
+
+ @Test
+ fun `type alias order does not affect the hash`() {
+ val forwards = stamp(personWith(), SchemaAliases(typeAliases = mapOf("Person" to linkedSetOf("Human", "Actor"))))
+ val backwards = stamp(personWith(), SchemaAliases(typeAliases = mapOf("Person" to linkedSetOf("Actor", "Human"))))
+
+ assertEquals(forwards.contentHash, backwards.contentHash)
+ }
+
+ @Test
+ fun `a type alias and a property alias of the same name hash differently`() {
+ // The two blocks carry different tags and sit in different places, so declaring "old"
+ // as a former type name is a different schema from declaring it as a former property
+ // name.
+ val asTypeAlias = stamp(
+ personWith(ValuePropertyDefinition("age")),
+ SchemaAliases(typeAliases = mapOf("Person" to setOf("old"))),
+ )
+ val asPropertyAlias = stamp(
+ personWith(ValuePropertyDefinition("age")),
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("age" to setOf("old")))),
+ )
+
+ assertNotEquals(asTypeAlias.contentHash, asPropertyAlias.contentHash)
+ }
+
+ @Test
+ fun `aliases accumulate across successive renames`() {
+ val version = stamp(
+ DataDictionary.fromDomainTypes("test", listOf(DynamicType("C"))),
+ SchemaAliases(typeAliases = mapOf("C" to setOf("A", "B"))),
+ )
+
+ assertEquals(setOf("A", "B"), version.entityTypeAliases["C"])
+ }
+
+ @Test
+ fun `a type may list its own name, which is what a rename and back leaves behind`() {
+ // A renamed to B and back to A accumulates {A, B}, so the alias set holds the current
+ // name. The reuse guard is about other types' names.
+ val version = stamp(
+ DataDictionary.fromDomainTypes("test", listOf(DynamicType("A"))),
+ SchemaAliases(typeAliases = mapOf("A" to setOf("A", "B"))),
+ )
+
+ assertEquals(setOf("A", "B"), version.entityTypeAliases["A"])
+ }
+
+ @Test
+ fun `aliases for an ungoverned type are dropped`() {
+ // Everything else about an ungoverned type is invisible to the stamp; aliases follow.
+ val dictionary = DataDictionary.fromDomainTypes(
+ "test",
+ listOf(
+ DynamicType("Person"),
+ DynamicType(name = "Sighting", ownProperties = listOf(ValuePropertyDefinition("seenAt"))),
+ ),
+ )
+ val governed = GovernedTypeSelector { it.name == "Person" }
+
+ val plain = MetamodelVersion.from(dictionary, governed)
+ val aliased = MetamodelVersion.from(
+ dictionary,
+ governed,
+ SchemaAliases(
+ typeAliases = mapOf("Sighting" to setOf("Observation")),
+ propertyAliases = mapOf("Sighting" to mapOf("seenAt" to setOf("spottedAt"))),
+ ),
+ )
+
+ assertEquals(emptyMap>(), aliased.entityTypeAliases)
+ assertEquals(plain.contentHash, aliased.contentHash)
+ }
+
+ @Test
+ fun `an explicitly empty alias set is dropped rather than hashed`() {
+ val plain = stamp(personWith(ValuePropertyDefinition("age")), SchemaAliases.NONE)
+ val declaredEmpty = stamp(
+ personWith(ValuePropertyDefinition("age")),
+ SchemaAliases(
+ typeAliases = mapOf("Person" to emptySet()),
+ propertyAliases = mapOf("Person" to mapOf("age" to emptySet())),
+ ),
+ )
+
+ assertEquals(emptyMap>(), declaredEmpty.entityTypeAliases)
+ assertEquals(plain.contentHash, declaredEmpty.contentHash)
+ }
+ }
+
+ @Nested
+ inner class SignatureOrdering {
+
+ private fun signature(name: String, aliases: Set = emptySet()): PropertySignature =
+ PropertySignature(name, PropertySignature.Kind.VALUE, "string", Cardinality.ONE, aliases)
+
+ @Test
+ fun `aliases break ties only after name, kind, type and cardinality`() {
+ val aliasedAge = signature("age", setOf("zzz"))
+ val plainEmail = signature("email")
+
+ // The name still decides, whatever the aliases say.
+ assertTrue(aliasedAge < plainEmail)
+
+ val aliasedString = PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, setOf("zzz"),
+ )
+ val plainInteger = PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE,
+ )
+ assertTrue(plainInteger < aliasedString)
+ }
+
+ @Test
+ fun `signatures differing only in aliases sort deterministically`() {
+ val none = signature("age")
+ val one = signature("age", setOf("b"))
+ val two = signature("age", setOf("a1", "b"))
+
+ assertTrue(none < two)
+ assertTrue(two < one)
+ assertEquals(listOf(none, two, one), listOf(one, none, two).sorted())
+ assertEquals(listOf(none, two, one), listOf(two, one, none).sorted())
+ }
+
+ @Test
+ fun `alias order inside the set does not change the ordering`() {
+ val forwards = signature("age", linkedSetOf("a", "b"))
+ val backwards = signature("age", linkedSetOf("b", "a"))
+
+ assertEquals(0, forwards.compareTo(backwards))
+ }
+ }
+
+ @Nested
+ inner class AliasGuards {
+
+ private fun personTwice(vararg propertyTypes: String): DataDictionary =
+ DataDictionary.fromDomainTypes(
+ "test",
+ propertyTypes.map { propertyType ->
+ DynamicType(
+ name = "Person",
+ ownProperties = listOf(ValuePropertyDefinition("age", type = propertyType)),
+ )
+ },
+ )
+
+ @Test
+ fun `type aliases keyed by a type that is not listed are rejected`() {
+ val thrown = assertThrows {
+ MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = emptyMap(),
+ relationshipNames = emptyList(),
+ entityTypeAliases = mapOf("Ghost" to setOf("Spectre")),
+ )
+ }
+ assertTrue(thrown.message!!.contains("entityTypeAliases"), thrown.message)
+ assertTrue(thrown.message!!.contains("Ghost"), thrown.message)
+ }
+
+ @Test
+ fun `an empty type alias set is rejected`() {
+ // An entry with no former names in it hashes differently from having no entry at all,
+ // while meaning the same thing, so two stamps of one schema could land on two keys.
+ val thrown = assertThrows {
+ MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = emptyMap(),
+ relationshipNames = emptyList(),
+ entityTypeAliases = mapOf("Person" to emptySet()),
+ )
+ }
+ assertTrue(thrown.message!!.contains("empty alias sets"), thrown.message)
+ assertTrue(thrown.message!!.contains("Person"), thrown.message)
+ }
+
+ @Test
+ fun `a declared type name in another type's alias set is rejected by the constructor`() {
+ val thrown = assertThrows {
+ MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Human", "Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = emptyMap(),
+ relationshipNames = emptyList(),
+ entityTypeAliases = mapOf("Human" to setOf("Person")),
+ )
+ }
+ assertTrue(thrown.message!!.contains("Human"), thrown.message)
+ assertTrue(thrown.message!!.contains("Person"), thrown.message)
+ assertTrue(thrown.message!!.contains("Retire the alias"), thrown.message)
+ }
+
+ @Test
+ fun `a declared type name in another type's alias set is rejected at the stamping seam`() {
+ val dictionary = DataDictionary.fromDomainTypes(
+ "test",
+ listOf(DynamicType("Human"), DynamicType("Person")),
+ )
+
+ val thrown = assertThrows {
+ MetamodelVersion.from(
+ dictionary,
+ GovernedTypeSelector.ALL,
+ SchemaAliases(typeAliases = mapOf("Human" to setOf("Person"))),
+ )
+ }
+ assertTrue(thrown.message!!.contains("Person"), thrown.message)
+ }
+
+ @Test
+ fun `reusing the name of an ungoverned type is allowed, because the stamp never sees it`() {
+ val dictionary = DataDictionary.fromDomainTypes(
+ "test",
+ listOf(DynamicType("Human"), DynamicType("Person")),
+ )
+
+ val version = MetamodelVersion.from(
+ dictionary,
+ GovernedTypeSelector { it.name == "Human" },
+ SchemaAliases(typeAliases = mapOf("Human" to setOf("Person"))),
+ )
+ assertEquals(setOf("Person"), version.entityTypeAliases["Human"])
+ }
+
+ @Test
+ fun `aliases on a property name with more than one signature are rejected by the constructor`() {
+ val thrown = assertThrows {
+ MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = mapOf(
+ "Person" to setOf(
+ PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, setOf("years"),
+ ),
+ PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "integer", Cardinality.ONE, setOf("years"),
+ ),
+ ),
+ ),
+ relationshipNames = emptyList(),
+ )
+ }
+ assertTrue(thrown.message!!.contains("age"), thrown.message)
+ assertTrue(thrown.message!!.contains("years"), thrown.message)
+ assertTrue(thrown.message!!.contains("Retire the alias"), thrown.message)
+ }
+
+ @Test
+ fun `aliases on a property name with more than one signature are rejected at the stamping seam`() {
+ // Two same-named Person declarations each carry their own `age`, so the union holds two
+ // signatures for one name and an old name can't say which it meant.
+ val thrown = assertThrows {
+ MetamodelVersion.from(
+ personTwice("string", "integer"),
+ GovernedTypeSelector.ALL,
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("age" to setOf("years")))),
+ )
+ }
+ assertTrue(thrown.message!!.contains("age"), thrown.message)
+ assertTrue(thrown.message!!.contains("years"), thrown.message)
+ }
+
+ @Test
+ fun `a duplicated property name with no aliases declared is fine`() {
+ val version = MetamodelVersion.from(personTwice("string", "integer"))
+ assertEquals(2, version.entityTypeProperties["Person"]!!.size)
+ }
+
+ @Test
+ fun `aliases on a single-signature property survive a duplicate elsewhere on the type`() {
+ val dictionary = DataDictionary.fromDomainTypes(
+ "test",
+ listOf(
+ DynamicType(
+ name = "Person",
+ ownProperties = listOf(
+ ValuePropertyDefinition("age", type = "string"),
+ ValuePropertyDefinition("emailAddress"),
+ ),
+ ),
+ DynamicType(
+ name = "Person",
+ ownProperties = listOf(ValuePropertyDefinition("age", type = "integer")),
+ ),
+ ),
+ )
+
+ val version = MetamodelVersion.from(
+ dictionary,
+ GovernedTypeSelector.ALL,
+ SchemaAliases(propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email")))),
+ )
+
+ assertEquals(
+ setOf("email"),
+ version.entityTypeProperties["Person"]!!.single { it.name == "emailAddress" }.aliases,
+ )
+ }
+ }
+
+ @Nested
+ inner class Provenance {
+
+ /** One fixed schema, stamped with whatever provenance a test wants on it. */
+ private fun personStamp(
+ origin: StampProvenance? = null,
+ lastStamped: StampProvenance? = null,
+ ): MetamodelVersion = MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = mapOf("Person" to setOf("Person")),
+ entityTypeProperties = mapOf("Person" to emptySet()),
+ relationshipNames = emptyList(),
+ entityTypeAliases = emptyMap(),
+ origin = origin,
+ lastStamped = lastStamped,
+ )
+
+ @Test
+ fun `provenance defaults to absent`() {
+ val version = MetamodelVersion.from(
+ DataDictionary.fromDomainTypes("test", listOf(DynamicType("Person"))),
+ )
+ assertNull(version.origin)
+ assertNull(version.lastStamped)
+ }
+
+ @Test
+ fun `provenance is carried`() {
+ val version = personStamp(
+ origin = StampProvenance("deploy-pipeline", "release-1"),
+ lastStamped = StampProvenance("operator", "manual-recheck"),
+ )
+
+ assertEquals(StampProvenance("deploy-pipeline", "release-1"), version.origin)
+ assertEquals(StampProvenance("operator", "manual-recheck"), version.lastStamped)
+ }
+
+ @Test
+ fun `provenance never reaches the content hash`() {
+ // Two stamps of one schema taken for different reasons are the same schema, and the
+ // hash is the store's natural key.
+ val bare = personStamp()
+ val attributed = personStamp(
+ origin = StampProvenance("deploy-pipeline", "release-1"),
+ lastStamped = StampProvenance("operator", "manual-recheck"),
+ )
+
+ assertEquals(bare.contentHash, attributed.contentHash)
+ assertTrue(bare.hasSameContentAs(attributed))
+ }
+
+ @Test
+ fun `provenance is not part of equality`() {
+ val bare = personStamp()
+ val attributed = personStamp(origin = StampProvenance("deploy-pipeline", "release-1"))
+
+ assertEquals(bare, attributed)
+ assertEquals(bare.hashCode(), attributed.hashCode())
+ }
+
+ @Test
+ fun `both fields are optional`() {
+ assertNull(StampProvenance().actor)
+ assertNull(StampProvenance().trigger)
+ assertEquals("ci", StampProvenance("ci").actor)
+ assertNull(StampProvenance("ci").trigger)
+ }
+
+ @Test
+ fun `an actor at the cap is accepted and one over it is rejected`() {
+ assertEquals(
+ StampProvenance.MAX_LENGTH,
+ StampProvenance(actor = "a".repeat(StampProvenance.MAX_LENGTH)).actor!!.length,
+ )
+
+ val thrown = assertThrows {
+ StampProvenance(actor = "a".repeat(StampProvenance.MAX_LENGTH + 1))
+ }
+ assertTrue(thrown.message!!.contains("actor"), thrown.message)
+ }
+
+ @Test
+ fun `a trigger at the cap is accepted and one over it is rejected`() {
+ assertEquals(
+ StampProvenance.MAX_LENGTH,
+ StampProvenance(trigger = "t".repeat(StampProvenance.MAX_LENGTH)).trigger!!.length,
+ )
+
+ val thrown = assertThrows {
+ StampProvenance(trigger = "t".repeat(StampProvenance.MAX_LENGTH + 1))
+ }
+ assertTrue(thrown.message!!.contains("trigger"), thrown.message)
+ }
+ }
+
+ @Nested
+ inner class AliasImmutability {
+
+ @Test
+ fun `the alias collections a stamp hands back cannot be mutated`() {
+ val version = MetamodelVersion.from(
+ DataDictionary.fromDomainTypes(
+ "test",
+ listOf(DynamicType(name = "Person", ownProperties = listOf(ValuePropertyDefinition("age")))),
+ ),
+ GovernedTypeSelector.ALL,
+ SchemaAliases(
+ typeAliases = mapOf("Person" to setOf("Human")),
+ propertyAliases = mapOf("Person" to mapOf("age" to setOf("years"))),
+ ),
+ )
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (version.entityTypeAliases as MutableMap>).remove("Person")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (version.entityTypeAliases["Person"] as MutableSet).add("Sneaky")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (version.entityTypeProperties["Person"]!!.single().aliases as MutableSet).add("Sneaky")
+ }
+ }
+
+ @Test
+ fun `mutating the alias sets the caller passed in does not change the stamp`() {
+ val typeAliases = mutableSetOf("Human")
+ val signatureAliases = mutableSetOf("years")
+ val version = MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = mapOf(
+ "Person" to setOf(
+ PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, signatureAliases,
+ ),
+ ),
+ ),
+ relationshipNames = emptyList(),
+ entityTypeAliases = mapOf("Person" to typeAliases),
+ )
+ val hashAtConstruction = version.contentHash
+
+ typeAliases.add("Actor")
+ signatureAliases.add("yearsOld")
+
+ assertEquals(setOf("Human"), version.entityTypeAliases["Person"])
+ assertEquals(setOf("years"), version.entityTypeProperties["Person"]!!.single().aliases)
+ assertEquals(hashAtConstruction, version.contentHash)
+ }
+
+ @Test
+ fun `filling in an alias set that was empty at construction does not change the stamp`() {
+ // The empty case is the dangerous one. A signature built with an empty mutable set that
+ // the stamp stored by reference would change its own hashCode when the caller added an
+ // alias, leaving it unfindable in the hash-based set holding it and disagreeing with a
+ // contentHash computed while it looked alias-free.
+ val aliases = mutableSetOf()
+ val signature = PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, aliases,
+ )
+ val version = MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = mapOf("Person" to setOf(signature)),
+ relationshipNames = emptyList(),
+ )
+ val hashAtConstruction = version.contentHash
+
+ aliases.add("years")
+
+ val stored = version.entityTypeProperties["Person"]!!
+ assertEquals(emptySet(), stored.single().aliases)
+ assertEquals(hashAtConstruction, version.contentHash)
+
+ // The signature is still findable under the identity it was hashed with, so nothing has
+ // shifted position in the set that holds it.
+ assertTrue(
+ stored.contains(
+ PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)
+ ),
+ )
+
+ // And the stamp still hashes as the alias-free schema it was built from.
+ val neverAliased = MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = mapOf(
+ "Person" to setOf(
+ PropertySignature("age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE)
+ ),
+ ),
+ relationshipNames = emptyList(),
+ )
+ assertEquals(neverAliased.contentHash, version.contentHash)
+ }
+
+ @Test
+ fun `an initially empty alias set is replaced by an immutable one`() {
+ val signature = PropertySignature(
+ "age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, mutableSetOf(),
+ )
+ val version = MetamodelVersion(
+ schemaName = "test",
+ entityTypeNames = listOf("Person"),
+ entityTypeLabels = emptyMap(),
+ entityTypeProperties = mapOf("Person" to setOf(signature)),
+ relationshipNames = emptyList(),
+ )
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (version.entityTypeProperties["Person"]!!.single().aliases as MutableSet)
+ .add("Sneaky")
+ }
+ }
+ }
}
diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/SchemaAliasesTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/SchemaAliasesTest.kt
new file mode 100644
index 00000000..23450d8d
--- /dev/null
+++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/SchemaAliasesTest.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.metamodel
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class SchemaAliasesTest {
+
+ @Test
+ fun `NONE declares nothing`() {
+ assertEquals(emptyMap>(), SchemaAliases.NONE.typeAliases)
+ assertEquals(emptyMap>>(), SchemaAliases.NONE.propertyAliases)
+ assertEquals(SchemaAliases(), SchemaAliases.NONE)
+ }
+
+ @Test
+ fun `an empty type alias set is dropped`() {
+ // Saying a type has no former names is the same as saying nothing about it, and an empty
+ // entry would otherwise be refused by the stamp's guard.
+ val aliases = SchemaAliases(typeAliases = mapOf("Person" to emptySet(), "Company" to setOf("Corp")))
+
+ assertEquals(mapOf("Company" to setOf("Corp")), aliases.typeAliases)
+ }
+
+ @Test
+ fun `an empty property alias set is dropped, and a type left with none goes with it`() {
+ val aliases = SchemaAliases(
+ propertyAliases = mapOf(
+ "Person" to mapOf("age" to emptySet(), "emailAddress" to setOf("email")),
+ "Company" to mapOf("name" to emptySet()),
+ ),
+ )
+
+ assertEquals(mapOf("Person" to mapOf("emailAddress" to setOf("email"))), aliases.propertyAliases)
+ }
+
+ @Test
+ fun `propertyAliasesFor answers empty for anything undeclared`() {
+ val aliases = SchemaAliases(
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))),
+ )
+
+ assertEquals(setOf("email"), aliases.propertyAliasesFor("Person", "emailAddress"))
+ assertEquals(emptySet(), aliases.propertyAliasesFor("Person", "age"))
+ assertEquals(emptySet(), aliases.propertyAliasesFor("Company", "emailAddress"))
+ }
+
+ @Test
+ fun `alias names are case-sensitive`() {
+ // LLM extraction drifts on case. Folding it here would pair two names nobody said were the
+ // same one.
+ val aliases = SchemaAliases(typeAliases = mapOf("Person" to setOf("Human")))
+
+ assertNotEquals(aliases, SchemaAliases(typeAliases = mapOf("Person" to setOf("human"))))
+ assertNotEquals(aliases, SchemaAliases(typeAliases = mapOf("person" to setOf("Human"))))
+ }
+
+ @Test
+ fun `mutating what the caller passed in does not change the declaration`() {
+ val types = mutableMapOf("Person" to mutableSetOf("Human"))
+ val properties = mutableMapOf("Person" to mutableMapOf("emailAddress" to mutableSetOf("email")))
+
+ val aliases = SchemaAliases(types, properties)
+
+ types["Ghost"] = mutableSetOf("Spectre")
+ types["Person"]!!.add("Actor")
+ properties["Person"]!!["age"] = mutableSetOf("years")
+ properties["Person"]!!["emailAddress"]!!.add("contact")
+
+ assertEquals(mapOf("Person" to setOf("Human")), aliases.typeAliases)
+ assertEquals(mapOf("Person" to mapOf("emailAddress" to setOf("email"))), aliases.propertyAliases)
+ }
+
+ @Test
+ fun `the collections a declaration hands back cannot be mutated`() {
+ val aliases = SchemaAliases(
+ typeAliases = mapOf("Person" to setOf("Human")),
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))),
+ )
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (aliases.typeAliases as MutableMap>).remove("Person")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (aliases.typeAliases["Person"] as MutableSet).add("Sneaky")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (aliases.propertyAliases as MutableMap>>).remove("Person")
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ assertThrows {
+ (aliases.propertyAliases["Person"]!!["emailAddress"] as MutableSet).add("Sneaky")
+ }
+ }
+
+ @Test
+ fun `equality is by content`() {
+ val one = SchemaAliases(
+ typeAliases = mapOf("Person" to setOf("Human")),
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))),
+ )
+ val two = SchemaAliases(
+ typeAliases = mapOf("Person" to setOf("Human")),
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))),
+ )
+
+ assertEquals(one, two)
+ assertEquals(one.hashCode(), two.hashCode())
+ assertTrue(one.toString().contains("Human"))
+ }
+}
diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md
index cdb8ead7..9643ffc8 100644
--- a/docs/design/metamodel-versioning.md
+++ b/docs/design/metamodel-versioning.md
@@ -13,8 +13,10 @@ the stamp is about, and keeping the stamps so history is answerable. Comparing t
stamp against a live graph, comes later — see [the tiers ahead](#the-tiers-ahead).
The types live in `dice-metamodel`, a small pure-JVM module: `MetamodelVersion`,
-`GovernedTypeSelector`, `DeclaredSchema`/`DeclaredSchemaSource`, and the `MetamodelVersionStore`
-contract. It depends on Embabel's agent core types and nothing else.
+`GovernedTypeSelector`, `DeclaredSchema`/`DeclaredSchemaSource`, `SchemaAliases`, `StampProvenance`,
+and the `MetamodelVersionStore` contract. It depends on Embabel's agent core types and nothing else.
+`SchemaAliases`, `StampProvenance`, and the alias fields on `PropertySignature` and
+`MetamodelVersion` are experimental; their shape may change before 1.0.
## Declare, stamp, store
@@ -74,9 +76,26 @@ routinely contain `;`, `[`, `=`, and spaces. A delimiter-joined encoding would l
Length-prefixing makes the encoding unambiguous, so distinct content always yields a distinct hash.
The hashed form is `types:|` followed, for each type name in sorted order, by the length-prefixed
-name, then `labels:|` and its sorted labels, then `props:|` and its sorted signatures — each
-signature contributing name, kind, type, and cardinality as four length-prefixed tokens. Then
-`rels:|` and the sorted relationship descriptors. The schema name appears nowhere.
+name, an optional `typealiases:|` block, then `labels:|` and its sorted labels, then
+`props:|` and its sorted signatures — each signature contributing name, kind, type, and
+cardinality as four length-prefixed tokens, followed by an optional `aliases:|` block. Then
+`rels:|` and the sorted relationship descriptors. The schema name appears nowhere, and neither
+does stamp provenance.
+
+The two alias blocks are written only when they hold something, which is what lets aliases be added
+to a shipped encoding at all. A schema that declares no former names renders exactly the bytes this
+encoding produced before aliases existed, so every hash already recorded against it still resolves.
+`MetamodelVersionTest` pins that. One test asserts the golden digest for the same dictionary stamped
+four ways: `from(dictionary)`, `from(dictionary, GovernedTypeSelector.ALL)`, the same with an
+explicit `SchemaAliases.NONE`, and the same with an explicitly empty
+`SchemaAliases(emptyMap(), emptyMap())`. A second asserts it for a stamp rebuilt field by field
+through the public constructor with an empty alias map, which is the path a storage mapper takes.
+
+The block shape is the one the rest of the encoding already uses: `:|` and then
+length-prefixed entries in sorted order. Position keeps the two tags apart — the type block sits
+between a type's name and its labels, the property block after a signature's fourth token — so
+declaring `old` as a former type name and declaring it as a former property name are different
+digests.
Two things about the input. A `DataDictionary` can legally hold two domain types sharing a name but
differing in shape, so `from` unions their labels and properties per name. Keeping only the last
@@ -85,7 +104,8 @@ The same split can render one relationship descriptor twice, so the constructor
deduplicates the type and relationship lists: declaring a type once or splitting it in two is the
same schema, and has to be the same hash.
-The constructor is strict about the rest, too. It copies every collection into a JVM-immutable one.
+The constructor is strict about the rest, too. It copies every collection into a JVM-immutable one,
+down to the alias set inside each property signature, which arrives however the caller built it.
Kotlin's read-only view is a compile-time promise that a Java caller sees straight through, so it
wouldn't stop anything being reshaped out from under the precomputed hash. The constructor also
rejects a label or property map keyed by a type missing from `entityTypeNames`: only listed types
@@ -154,6 +174,146 @@ Versioning starts here: with no declared schema, nothing is stamped. The Spring
in a later slice activates only when a `DeclaredSchemaSource` bean is present, so an application
that hasn't decided what it governs is left alone.
+## Declared renames
+
+Renaming a type or a property is the change a content hash reads worst. `email` becoming
+`emailAddress` is one property with a new spelling, and a stamp comparison sees a removal and an
+addition, which is the same shape as deleting a property and inventing an unrelated one. A declared
+alias says what the name used to be, so the comparison can pair the two.
+
+Iceberg and Delta hold identity in stable field ids assigned when a column is created, leaving the
+name as a label over an identity the format already carries. DICE has no id to assign. Its types
+come from LLM extraction against a `DataDictionary` an application edits, and the upstream
+`PropertyDefinition` has nowhere to put an id even if DICE minted one. The adapted form is the
+former name itself, declared at the moment the rename is made:
+
+```kotlin
+DeclaredSchema.from(
+ dataDictionary,
+ governed,
+ SchemaAliases(
+ typeAliases = mapOf("Organisation" to setOf("Company")),
+ propertyAliases = mapOf("Person" to mapOf("emailAddress" to setOf("email"))),
+ ),
+)
+```
+
+Types get the mechanism as well as properties because a type rename is the more destructive of the
+two: every proposition labelled with the old name and every referrer pointing at it move at once.
+
+Alias names are exact and case-sensitive. LLM extraction drifts on case, and folding `worksAt` into
+`worksat` here would pair two names nobody declared as the same one.
+
+Aliases accumulate. A type renamed `A` to `B` to `C` declares `{A, B}`, so a comparison across
+non-adjacent stamps still pairs. Retiring a name means deleting it from the declaration.
+
+`SchemaAliases` is a declaration-time input; the stamp carries the result. `MetamodelVersion.from`
+decorates each signature with the former names declared for its property, inside the per-type loop
+right after the signature is read, because the stamp is immutable and hashes at construction. Type
+aliases land in `entityTypeAliases`. Aliases declared for a type the selector doesn't govern are
+dropped, along with everything else about an ungoverned type.
+
+A property alias equal to the property's own name, or naming a property that still exists on both
+sides of a comparison, matches nothing: nothing looks data up by property name, so a stale property
+alias can mislead nothing. It stays part of the signature and moves the hash. A type alias naming
+another declared type is a different case, and the third guard below rejects it.
+
+Comparing two stamps is the next slice. Until it lands, an alias is a recorded intention that moves
+the hash and nothing more.
+
+### The four guards
+
+```mermaid
+flowchart TB
+ aliases["SchemaAliases
typeAliases, propertyAliases"]
+ seam["MetamodelVersion.from / DeclaredSchema.from"]
+ ctor["MetamodelVersion constructor
(also the storage reconstruction path)"]
+ g1{"alias keys all in
entityTypeNames?"}
+ g2{"every alias set
non-empty?"}
+ g3{"alias names another
declared type?"}
+ g4{"aliases on a property name
with two signatures?"}
+ stamp["Stamp: entityTypeAliases and
PropertySignature.aliases, both hashed"]
+ reject["IllegalArgumentException
naming the alias to retire"]
+
+ aliases --> seam
+ seam -->|"ungoverned keys dropped,
signatures decorated"| ctor
+ ctor --> g1
+ g1 -->|no| reject
+ g1 -->|yes| g2
+ g2 -->|no| reject
+ g2 -->|yes| g3
+ g3 -->|yes| reject
+ g3 -->|no| g4
+ g4 -->|yes| reject
+ g4 -->|no| stamp
+```
+
+**Alias-map keys are a subset of `entityTypeNames`.** Only listed types are walked when hashing, so
+an entry keyed by anything else never reaches `contentHash`. This is the rule the label and property
+maps already follow.
+
+**Every alias set is non-empty.** An entry mapping a type to no former names hashes differently from
+having no entry at all while saying the same thing, so two stamps of one schema could land on two
+different natural keys. `SchemaAliases` drops empty sets on the way in, which keeps a harmless
+declaration from becoming an error at the seam.
+
+**No declared type name appears in another type's alias set.** This is the reuse collision. A schema
+renames `Company` to `Organisation`, keeps `{Company}` as the alias, and later declares a fresh
+`Company` for something unrelated. A live type now shares a retired name, and the comparison has two
+bad options: sweep the new `Company`'s data into the renamed type's quarantine matching, or treat
+the old label as declared forever and never report drift on it. Reusing a retired name therefore
+requires deleting the alias that still claims it first. A type listing its own name is fine — a
+rename to `B` and back to `A` accumulates `{A, B}` — because the guard is about other types' names.
+
+**No aliases on a property name a type holds more than one signature for.** Two same-named domain
+types can each declare `age` with a different shape, and the union keeps both signatures. An old
+name has no way to say which of the two it meant, so the declaration is refused.
+
+All four run in the `MetamodelVersion` constructor, so the public constructors and the storage
+mapper's reconstruction path are covered. The last two also run at `MetamodelVersion.from` and
+`DeclaredSchema.from`, which is where duplicates and reused names actually become visible, and where
+the message can name the alias to retire.
+
+### What the duplicate-name refusal costs
+
+A dictionary can legally evolve into duplicate-hood. Someone adds a second `Person` declaration
+carrying its own `age`, and a name that had one signature now has two. If an alias was standing on
+that name, every stamp and every drift check from that moment throws, with a message naming the
+alias to retire. That is a loud, conservative hard stop on a schema that was fine the day before.
+
+Retiring the alias clears the throw and costs the pairing. The name then diffs through the duplicate
+fallback as a removal plus an addition, the drift policy reads that as lossy, and an additive
+evolution earns a quarantine sweep. Both outcomes cost more than an ordinary signature change would,
+and both beat the only other option, which is guessing which of the two signatures the old name
+referred to.
+
+## Stamp provenance
+
+A stamp records who caused it, in two pairs: `origin` for the first stamp of a schema, `lastStamped`
+for the most recent. Each is a `StampProvenance(actor, trigger)` of two opaque host-supplied
+strings, capped at 256 characters because they land in a database column and an unbounded
+host-supplied string is how a stamp write starts failing at the driver. The cap counts characters,
+which is what `String.length` gives; a storage backend sizes its column in bytes, so 256 characters
+needs room for the 1024 bytes UTF-8 can take to encode them.
+
+A single pair would answer only the second question. The drift check that arrives in a later slice
+re-stamps on every boot and on a schedule, so one last-writer-wins field converges on whichever
+instance booted last, and the cause of the original stamp is gone within a deploy cycle. Keeping
+`origin` separate means the first cause survives every routine re-stamp.
+
+Snowflake's `SCHEMA_EVOLUTION_RECORD` is the same shape: the evolution event is recorded alongside
+the schema rather than folded into the schema's identity. Provenance is informational here for the
+same reason. Two stamps of one schema taken for different reasons are the same schema, and the hash
+is the store's natural key, so hashing the cause would give one schema as many identities as it had
+causes. It is excluded from `contentHash` and from equality.
+
+`trigger` is where an extraction-run reference lands once that exists. It stays a plain string, so
+this module gains no dependency on the run model.
+
+The persistence rules that keep those two pairs alive through routine re-saves belong to the storage
+slice: `origin` is first-write-wins, and `lastStamped` moves only when the incoming value is
+non-null, so a routine re-stamp carrying no provenance can never erase cause.
+
## History accumulates
`MetamodelVersionStore` is a port with four operations: `saveVersion`, `latestVersion`,
@@ -166,6 +326,14 @@ carries the content: the hash is derived from exactly the fields a re-save would
anything landing on an existing key has identical content by construction. The interface doesn't
promise append-only storage, and an implementation isn't expected to reject a re-save.
+This is where schema registries have already landed. AWS Glue Schema Registry and Confluent Schema
+Registry both identify a schema version by a fingerprint of its content and answer a re-registration
+of an identical schema with the existing version rather than a new one. Deriving identity from the
+content is what makes registration idempotent, and it is why a client that re-registers on every
+boot doesn't inflate the history. DICE keys on `(schemaName, contentHash)` for that reason, and an
+application stamping its declared schema on every start is exactly the client those registries are
+built for.
+
`findVersion` resolves a recorded hash back to the schema shape it named. The default scans
`versionHistory`, which is correct for any implementation but reads the whole history to answer a
keyed question; a backend that can push the lookup down to the database should override it. This
From d8bd3188162b49ed4060a203ce2e7ef4519721cd Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Mon, 31 Aug 2026 14:25:09 -0400
Subject: [PATCH 04/11] Remove stamp provenance until a caller records it
---
CHANGELOG.md | 15 +--
.../dice/metamodel/MetamodelVersion.kt | 62 +----------
.../metamodel/MetamodelJavaCompatTest.java | 26 ++---
.../dice/metamodel/MetamodelVersionTest.kt | 100 +-----------------
docs/design/metamodel-versioning.md | 48 ++++-----
5 files changed, 37 insertions(+), 214 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0421adb0..fdd37fcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,13 +15,11 @@ and the consumer PRs that deliver it).
opt-in seam, and the `MetamodelVersionStore` contract. Pure JVM.
**Compatibility: additive.** New module; no existing API touched.
-- Declared renames and stamp provenance in `dice-metamodel`. **EXPERIMENTAL**
- (shape may change before 1.0): `SchemaAliases`, `StampProvenance`,
- `PropertySignature.aliases`, `MetamodelVersion.entityTypeAliases`, and
- `MetamodelVersion.origin`/`lastStamped`. A declaration states the names a type
- or property used to go by, so a later comparison pairs a rename instead of
- reading it as a removal and an addition; provenance records who caused the
- first and the most recent stamp, and is never hashed.
+- Declared renames in `dice-metamodel`. **EXPERIMENTAL** (shape may change
+ before 1.0): `SchemaAliases`, `PropertySignature.aliases`, and
+ `MetamodelVersion.entityTypeAliases`. A declaration states the names a type or
+ property used to go by, so a later comparison pairs a rename instead of
+ reading it as a removal and an addition.
**Compatibility: additive.** `contentHash` is unchanged for any schema that
declares no aliases — the new hash blocks serialize only when non-empty, and
the pinned golden digest is asserted unchanged, including for a stamp rebuilt
@@ -39,6 +37,3 @@ and the consumer PRs that deliver it).
static form. The changed Kotlin synthetic constructor, `copy`, `copy$default`
and `componentN` signatures on `PropertySignature` are the accepted boundary:
Kotlin callers recompile, and no consumer holds a compiled reference to them.
- `StampProvenance` caps `actor` and `trigger` at 256 **characters**
- (`String.length`), so a storage backend sizing a column in bytes needs room
- for the up-to-1024 UTF-8 bytes those characters can take.
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
index 3d1d12aa..1d476181 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
@@ -114,51 +114,6 @@ data class PropertySignature @JvmOverloads constructor(
}
}
-/**
- * Who or what caused a stamp to be taken.
- *
- * Both fields are opaque strings the host supplies and DICE never interprets. `actor` is whoever
- * asked — a deploy pipeline, an operator, a scheduled job. `trigger` is what prompted it, and is
- * where an extraction-run reference lands once that exists; keeping it a plain string means this
- * module gains no dependency on the run model.
- *
- * Provenance never reaches [MetamodelVersion.contentHash] and never affects equality. It is
- * informational: two stamps of the same schema taken for different reasons are the same schema.
- * Hashing it would give one schema as many content hashes as it had causes, and the store keys on
- * that hash.
- *
- * Both fields are capped at [MAX_LENGTH] **characters**, counted as `String.length` (UTF-16 code
- * units). They land in a database column, and an unbounded host-supplied string is how a stamp
- * write starts failing at the driver. A storage backend sizing a column has to size it in bytes: a
- * 256-character value can reach 1024 bytes in UTF-8, and more once surrogate pairs are involved, so
- * the column needs headroom rather than a matching 256.
- *
- * Experimental: shape may change before 1.0.
- *
- * @property actor Who asked for the stamp. Null when the host didn't say.
- * @property trigger What prompted it. Null when the host didn't say.
- */
-data class StampProvenance @JvmOverloads constructor(
- val actor: String? = null,
- val trigger: String? = null,
-) {
-
- init {
- require(actor == null || actor.length <= MAX_LENGTH) {
- "actor is ${actor!!.length} characters; the cap is $MAX_LENGTH."
- }
- require(trigger == null || trigger.length <= MAX_LENGTH) {
- "trigger is ${trigger!!.length} characters; the cap is $MAX_LENGTH."
- }
- }
-
- companion object {
-
- /** Longest an [actor] or [trigger] may be, in characters. */
- const val MAX_LENGTH: Int = 256
- }
-}
-
/**
* An immutable stamp that captures the identity and structural content of the governed part of a
* [DataDictionary] at a point in time.
@@ -192,14 +147,9 @@ data class StampProvenance @JvmOverloads constructor(
* type rename pairs up instead of reading as a type vanishing and another appearing. Declared
* through [SchemaAliases] and empty unless someone declared them. Hashed, so an alias-only edit
* moves [contentHash]. Experimental: shape may change before 1.0.
- * @property origin Who or what caused this schema to be stamped for the first time. Informational,
- * never hashed. Experimental: shape may change before 1.0.
- * @property lastStamped Who or what caused the most recent stamp of this schema. Informational,
- * never hashed. Experimental: shape may change before 1.0.
* @property contentHash SHA-256 hex digest of the schema's entity types, label sets, property
* signatures, type aliases, and allowed relationships. The schema name is excluded so that two
- * structurally identical schemas are equal regardless of how they are named, and provenance is
- * excluded because the cause of a stamp doesn't change the schema it stamps. Stable across JVM
+ * structurally identical schemas are equal regardless of how they are named. Stable across JVM
* restarts. Any structural change produces a different hash, including a property's type changing
* on a type whose name is unchanged.
*/
@@ -210,8 +160,6 @@ class MetamodelVersion @JvmOverloads constructor(
entityTypeProperties: Map>,
relationshipNames: List,
entityTypeAliases: Map> = emptyMap(),
- val origin: StampProvenance? = null,
- val lastStamped: StampProvenance? = null,
) {
val schemaName: String = schemaName
@@ -317,11 +265,7 @@ class MetamodelVersion @JvmOverloads constructor(
return hashBytes.joinToString("") { "%02x".format(it) }
}
- /**
- * Structural equality. Provenance is left out for the same reason it is left out of
- * [contentHash]: it records why a stamp was taken, and two stamps of one schema taken for
- * different reasons are still one schema.
- */
+ /** Structural equality: same schema name, types, labels, property signatures, relationships, and aliases. */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MetamodelVersion) return false
@@ -346,7 +290,7 @@ class MetamodelVersion @JvmOverloads constructor(
"MetamodelVersion(schemaName=$schemaName, contentHash=$contentHash, " +
"entityTypeNames=$entityTypeNames, entityTypeLabels=$entityTypeLabels, " +
"entityTypeProperties=$entityTypeProperties, relationshipNames=$relationshipNames, " +
- "entityTypeAliases=$entityTypeAliases, origin=$origin, lastStamped=$lastStamped)"
+ "entityTypeAliases=$entityTypeAliases)"
companion object {
diff --git a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
index 91c8e359..8140f4f4 100644
--- a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
+++ b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
@@ -28,17 +28,16 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Calls every entry point the way a Java consumer compiled against the previous release does.
*
- * Aliases and provenance were added as trailing parameters with defaults, so a Kotlin caller sees
- * no change. A Java caller sees whatever descriptors the compiler emitted, which is why
- * {@code @JvmOverloads} is on those constructors and factories: without it, adding a parameter
- * would delete the descriptor an already-compiled consumer is linked against, and the failure
- * would be a {@code NoSuchMethodError} at runtime rather than a compile error here.
+ * Aliases were added as trailing parameters with defaults, so a Kotlin caller sees no change. A
+ * Java caller sees whatever descriptors the compiler emitted, which is why {@code @JvmOverloads} is
+ * on those constructors and factories: without it, adding a parameter would delete the descriptor
+ * an already-compiled consumer is linked against, and the failure would be a
+ * {@code NoSuchMethodError} at runtime rather than a compile error here.
*/
class MetamodelJavaCompatTest {
@@ -79,26 +78,19 @@ void theFiveArgumentMetamodelVersionConstructorStillExists() {
assertEquals(List.of("Person"), version.getEntityTypeNames());
assertEquals(Map.of(), version.getEntityTypeAliases());
- assertNull(version.getOrigin());
- assertNull(version.getLastStamped());
}
@Test
- void theMetamodelVersionConstructorAlsoTakesAliasesAndProvenance() {
+ void theMetamodelVersionConstructorAlsoTakesAliases() {
MetamodelVersion version = new MetamodelVersion(
"test",
List.of("Person"),
Map.of("Person", Set.of("Person")),
Map.of("Person", Set.of()),
List.of(),
- Map.of("Person", Set.of("Human")),
- new StampProvenance("deploy-pipeline", "release-1"),
- new StampProvenance("operator", null));
+ Map.of("Person", Set.of("Human")));
assertEquals(Set.of("Human"), version.getEntityTypeAliases().get("Person"));
- assertEquals("deploy-pipeline", version.getOrigin().getActor());
- assertEquals("operator", version.getLastStamped().getActor());
- assertNull(version.getLastStamped().getTrigger());
}
@Test
@@ -140,11 +132,9 @@ void theDeclarationFactoryAlsoTakesAliases() {
}
@Test
- void theNoArgumentAliasAndProvenanceConstructorsExist() {
+ void theNoArgumentAliasConstructorExists() {
assertEquals(Map.of(), new SchemaAliases().getTypeAliases());
assertEquals(Map.of(), SchemaAliases.NONE.getPropertyAliases());
- assertNull(new StampProvenance().getActor());
- assertEquals("ci", new StampProvenance("ci").getActor());
}
@Test
diff --git a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
index bc68a68e..7b5aa2f7 100644
--- a/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
+++ b/dice-metamodel/src/test/kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt
@@ -201,8 +201,8 @@ class MetamodelVersionTest {
@Test
fun `rebuilding the golden stamp through the public constructor hashes to the same literal`() {
// The storage mapper reconstructs a stamp field by field rather than from a dictionary.
- // Passing an explicitly empty alias map and no provenance has to reproduce the pinned
- // digest, or a row written before aliases existed could never be read back.
+ // Passing an explicitly empty alias map has to reproduce the pinned digest, or a row
+ // written before aliases existed could never be read back.
val fromDictionary = MetamodelVersion.from(goldenSchema())
val rebuilt = MetamodelVersion(
schemaName = fromDictionary.schemaName,
@@ -1290,102 +1290,6 @@ class MetamodelVersionTest {
}
}
- @Nested
- inner class Provenance {
-
- /** One fixed schema, stamped with whatever provenance a test wants on it. */
- private fun personStamp(
- origin: StampProvenance? = null,
- lastStamped: StampProvenance? = null,
- ): MetamodelVersion = MetamodelVersion(
- schemaName = "test",
- entityTypeNames = listOf("Person"),
- entityTypeLabels = mapOf("Person" to setOf("Person")),
- entityTypeProperties = mapOf("Person" to emptySet()),
- relationshipNames = emptyList(),
- entityTypeAliases = emptyMap(),
- origin = origin,
- lastStamped = lastStamped,
- )
-
- @Test
- fun `provenance defaults to absent`() {
- val version = MetamodelVersion.from(
- DataDictionary.fromDomainTypes("test", listOf(DynamicType("Person"))),
- )
- assertNull(version.origin)
- assertNull(version.lastStamped)
- }
-
- @Test
- fun `provenance is carried`() {
- val version = personStamp(
- origin = StampProvenance("deploy-pipeline", "release-1"),
- lastStamped = StampProvenance("operator", "manual-recheck"),
- )
-
- assertEquals(StampProvenance("deploy-pipeline", "release-1"), version.origin)
- assertEquals(StampProvenance("operator", "manual-recheck"), version.lastStamped)
- }
-
- @Test
- fun `provenance never reaches the content hash`() {
- // Two stamps of one schema taken for different reasons are the same schema, and the
- // hash is the store's natural key.
- val bare = personStamp()
- val attributed = personStamp(
- origin = StampProvenance("deploy-pipeline", "release-1"),
- lastStamped = StampProvenance("operator", "manual-recheck"),
- )
-
- assertEquals(bare.contentHash, attributed.contentHash)
- assertTrue(bare.hasSameContentAs(attributed))
- }
-
- @Test
- fun `provenance is not part of equality`() {
- val bare = personStamp()
- val attributed = personStamp(origin = StampProvenance("deploy-pipeline", "release-1"))
-
- assertEquals(bare, attributed)
- assertEquals(bare.hashCode(), attributed.hashCode())
- }
-
- @Test
- fun `both fields are optional`() {
- assertNull(StampProvenance().actor)
- assertNull(StampProvenance().trigger)
- assertEquals("ci", StampProvenance("ci").actor)
- assertNull(StampProvenance("ci").trigger)
- }
-
- @Test
- fun `an actor at the cap is accepted and one over it is rejected`() {
- assertEquals(
- StampProvenance.MAX_LENGTH,
- StampProvenance(actor = "a".repeat(StampProvenance.MAX_LENGTH)).actor!!.length,
- )
-
- val thrown = assertThrows {
- StampProvenance(actor = "a".repeat(StampProvenance.MAX_LENGTH + 1))
- }
- assertTrue(thrown.message!!.contains("actor"), thrown.message)
- }
-
- @Test
- fun `a trigger at the cap is accepted and one over it is rejected`() {
- assertEquals(
- StampProvenance.MAX_LENGTH,
- StampProvenance(trigger = "t".repeat(StampProvenance.MAX_LENGTH)).trigger!!.length,
- )
-
- val thrown = assertThrows {
- StampProvenance(trigger = "t".repeat(StampProvenance.MAX_LENGTH + 1))
- }
- assertTrue(thrown.message!!.contains("trigger"), thrown.message)
- }
- }
-
@Nested
inner class AliasImmutability {
diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md
index 9643ffc8..12b0fdbe 100644
--- a/docs/design/metamodel-versioning.md
+++ b/docs/design/metamodel-versioning.md
@@ -13,10 +13,10 @@ the stamp is about, and keeping the stamps so history is answerable. Comparing t
stamp against a live graph, comes later — see [the tiers ahead](#the-tiers-ahead).
The types live in `dice-metamodel`, a small pure-JVM module: `MetamodelVersion`,
-`GovernedTypeSelector`, `DeclaredSchema`/`DeclaredSchemaSource`, `SchemaAliases`, `StampProvenance`,
-and the `MetamodelVersionStore` contract. It depends on Embabel's agent core types and nothing else.
-`SchemaAliases`, `StampProvenance`, and the alias fields on `PropertySignature` and
-`MetamodelVersion` are experimental; their shape may change before 1.0.
+`GovernedTypeSelector`, `DeclaredSchema`/`DeclaredSchemaSource`, `SchemaAliases`, and the
+`MetamodelVersionStore` contract. It depends on Embabel's agent core types and nothing else.
+`SchemaAliases` and the alias fields on `PropertySignature` and `MetamodelVersion` are
+experimental; their shape may change before 1.0.
## Declare, stamp, store
@@ -79,8 +79,7 @@ The hashed form is `types:|` followed, for each type name in sorted order, by
name, an optional `typealiases:|` block, then `labels:|` and its sorted labels, then
`props:|` and its sorted signatures — each signature contributing name, kind, type, and
cardinality as four length-prefixed tokens, followed by an optional `aliases:|` block. Then
-`rels:|` and the sorted relationship descriptors. The schema name appears nowhere, and neither
-does stamp provenance.
+`rels:|` and the sorted relationship descriptors. The schema name appears nowhere.
The two alias blocks are written only when they hold something, which is what lets aliases be added
to a shipped encoding at all. A schema that declares no former names renders exactly the bytes this
@@ -287,32 +286,23 @@ evolution earns a quarantine sweep. Both outcomes cost more than an ordinary sig
and both beat the only other option, which is guessing which of the two signatures the old name
referred to.
-## Stamp provenance
+## Stamp provenance waits for a caller
-A stamp records who caused it, in two pairs: `origin` for the first stamp of a schema, `lastStamped`
-for the most recent. Each is a `StampProvenance(actor, trigger)` of two opaque host-supplied
-strings, capped at 256 characters because they land in a database column and an unbounded
-host-supplied string is how a stamp write starts failing at the driver. The cap counts characters,
-which is what `String.length` gives; a storage backend sizes its column in bytes, so 256 characters
-needs room for the 1024 bytes UTF-8 can take to encode them.
+A stamp says nothing about who or what caused it. That is deliberate for now. Recording the cause
+means fixing a type for it, deciding how long its strings may be, finding it somewhere to live in
+every backend, and settling what a re-save does to a value that is already there — a pile of
+commitments made on behalf of a caller that doesn't exist yet. Nothing in DICE stamps with a cause
+today: `MetamodelVersion.from` builds a stamp from a dictionary, and the drift check that arrives
+in a later slice re-stamps the
+declared schema without anything to attribute it to.
-A single pair would answer only the second question. The drift check that arrives in a later slice
-re-stamps on every boot and on a schedule, so one last-writer-wins field converges on whichever
-instance booted last, and the cause of the original stamp is gone within a deploy cycle. Keeping
-`origin` separate means the first cause survives every routine re-stamp.
+Provenance returns with the first stamping caller that records it. Whoever that caller is will
+settle the shape, which is a better basis for the decision than a guess made here.
-Snowflake's `SCHEMA_EVOLUTION_RECORD` is the same shape: the evolution event is recorded alongside
-the schema rather than folded into the schema's identity. Provenance is informational here for the
-same reason. Two stamps of one schema taken for different reasons are the same schema, and the hash
-is the store's natural key, so hashing the cause would give one schema as many identities as it had
-causes. It is excluded from `contentHash` and from equality.
-
-`trigger` is where an extraction-run reference lands once that exists. It stays a plain string, so
-this module gains no dependency on the run model.
-
-The persistence rules that keep those two pairs alive through routine re-saves belong to the storage
-slice: `origin` is first-write-wins, and `lastStamped` moves only when the incoming value is
-non-null, so a routine re-stamp carrying no provenance can never erase cause.
+Snowflake's `SCHEMA_EVOLUTION_RECORD` shows where it lands when it does arrive: the evolution event
+sits alongside the schema rather than inside the schema's identity. Two stamps of one schema taken
+for different reasons are the same schema, and the hash is the store's natural key, so hashing the
+cause would give one schema as many identities as it had causes.
## History accumulates
From 0a011f5783ca8216380c81c2a24717d34e91a5f4 Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Tue, 1 Sep 2026 13:00:14 -0400
Subject: [PATCH 05/11] Define the stamping contract for dice.metamodel.version
The key existed with nothing specifying who writes it or what the value
means. The KDoc now states the contract: the extraction persistence path
stamps the declared schema's content hash onto canonical proposition
metadata, a missing key marks pre-governance extraction, and the value
is opaque. A test proves the stamp round-trips through the in-memory
store and propositions are selectable by it. Production stamping lands
in a follow-up slice once the extraction-run stack merges.
---
CHANGELOG.md | 8 ++
.../embabel/dice/common/DiceMetadataKeys.kt | 15 ++-
.../MetamodelVersionStampingTest.kt | 104 ++++++++++++++++++
3 files changed, 125 insertions(+), 2 deletions(-)
create mode 100644 dice/src/test/kotlin/com/embabel/dice/proposition/MetamodelVersionStampingTest.kt
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdd37fcd..174f4124 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,3 +37,11 @@ and the consumer PRs that deliver it).
static form. The changed Kotlin synthetic constructor, `copy`, `copy$default`
and `componentN` signatures on `PropertySignature` are the accepted boundary:
Kotlin callers recompile, and no consumer holds a compiled reference to them.
+
+- `DiceMetadataKeys.METAMODEL_VERSION` metadata key and stamping contract.
+ Propositions can carry the declared schema version hash under this key to
+ record which schema governed their extraction. The key is defined here with
+ its contract; production wiring that stamps propositions at persistence time
+ lands in a follow-up slice after the extraction-run stack merges.
+ **Compatibility: additive.** New metadata key only; no existing API or code
+ touched.
diff --git a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt
index f2f48bf7..902dac75 100644
--- a/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt
+++ b/dice/src/main/kotlin/com/embabel/dice/common/DiceMetadataKeys.kt
@@ -36,8 +36,19 @@ object DiceMetadataKeys {
/**
* Content hash of the schema active at extraction time.
*
- * Stamping a proposition with this key lets drift detection later identify which
- * propositions were extracted under an older schema version.
+ * The extraction persistence path writes this key onto a canonical proposition's metadata map
+ * at persistence time to record which declared schema version governed its extraction. The value
+ * is the content hash computed by the metamodel versioning module for the schema snapshot in
+ * effect at extraction. The value is opaque: consumers should compare it by equality to detect
+ * schema changes across propositions, and should avoid parsing or inspecting its structure.
+ *
+ * A missing key means the proposition was extracted before schema governance was adopted and
+ * has no declared version. Downstream drift detection and version-aware operations must treat
+ * unversioned propositions explicitly (e.g., assume they came from a known prior schema, or
+ * exclude them from compatibility checks).
+ *
+ * Production wiring that stamps propositions at persistence time lands in a follow-up slice
+ * after the extraction-run stack merges.
*/
const val METAMODEL_VERSION = "dice.metamodel.version"
diff --git a/dice/src/test/kotlin/com/embabel/dice/proposition/MetamodelVersionStampingTest.kt b/dice/src/test/kotlin/com/embabel/dice/proposition/MetamodelVersionStampingTest.kt
new file mode 100644
index 00000000..f80eae7e
--- /dev/null
+++ b/dice/src/test/kotlin/com/embabel/dice/proposition/MetamodelVersionStampingTest.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024-2026 Embabel Pty Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.embabel.dice.proposition
+
+import com.embabel.agent.core.ContextId
+import com.embabel.dice.common.DiceMetadataKeys
+import com.embabel.dice.proposition.store.InMemoryPropositionRepository
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+class MetamodelVersionStampingTest {
+
+ private val testContextId = ContextId("test-context")
+ private val testContentHash = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0"
+
+ @Test
+ fun `proposition metadata with METAMODEL_VERSION survives save and retrieve`() {
+ val repo = InMemoryPropositionRepository()
+
+ val original = Proposition(
+ contextId = testContextId,
+ text = "Alice works at Acme",
+ mentions = listOf(EntityMention(span = "alice", type = "Person", resolvedId = "alice")),
+ confidence = 0.95,
+ ).withMetadataValue(DiceMetadataKeys.METAMODEL_VERSION, testContentHash)
+
+ val saved = repo.save(original)
+ val retrieved = repo.findById(saved.id)
+
+ assertNotNull(retrieved)
+ assertEquals(testContentHash, retrieved!!.metadata[DiceMetadataKeys.METAMODEL_VERSION])
+ }
+
+ @Test
+ fun `propositions can be selected by METAMODEL_VERSION metadata value`() {
+ val repo = InMemoryPropositionRepository()
+ val hash1 = "hash1"
+ val hash2 = "hash2"
+
+ val prop1 = Proposition(
+ contextId = testContextId,
+ text = "Alice works at Acme",
+ mentions = emptyList(),
+ confidence = 0.95,
+ ).withMetadataValue(DiceMetadataKeys.METAMODEL_VERSION, hash1)
+
+ val prop2 = Proposition(
+ contextId = testContextId,
+ text = "Bob works at Globex",
+ mentions = emptyList(),
+ confidence = 0.95,
+ ).withMetadataValue(DiceMetadataKeys.METAMODEL_VERSION, hash2)
+
+ val prop3 = Proposition(
+ contextId = testContextId,
+ text = "Carol works at Initech",
+ mentions = emptyList(),
+ confidence = 0.95,
+ ).withMetadataValue(DiceMetadataKeys.METAMODEL_VERSION, hash1)
+
+ repo.save(prop1)
+ repo.save(prop2)
+ repo.save(prop3)
+
+ val hash1Props = repo.findAll().filter {
+ it.metadata[DiceMetadataKeys.METAMODEL_VERSION] == hash1
+ }
+
+ assertEquals(2, hash1Props.size)
+ assertTrue(hash1Props.any { it.text.contains("Alice") })
+ assertTrue(hash1Props.any { it.text.contains("Carol") })
+ }
+
+ @Test
+ fun `proposition without METAMODEL_VERSION has no value for the key`() {
+ val repo = InMemoryPropositionRepository()
+
+ val prop = Proposition(
+ contextId = testContextId,
+ text = "Alice works at Acme",
+ mentions = emptyList(),
+ confidence = 0.95,
+ )
+
+ val saved = repo.save(prop)
+ val retrieved = repo.findById(saved.id)
+
+ assertNotNull(retrieved)
+ assertNull(retrieved!!.metadata[DiceMetadataKeys.METAMODEL_VERSION])
+ }
+}
From 1226c14d9cf9068046b5689563f4cc3fbce1854f Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:16:26 -0400
Subject: [PATCH 06/11] Retire the word seam from versioning docs and test
names
---
AGENTS.md | 2 +-
README.md | 2 +-
dice-metamodel/pom.xml | 2 +-
.../kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt | 2 +-
.../kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt | 2 +-
.../kotlin/com/embabel/dice/metamodel/MetamodelVersionTest.kt | 4 ++--
6 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index e69d15d0..a789a8b9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ DICE (Domain-Integrated Context Engineering) is a proposition-first knowledge su
| `dice-storage-autoconfigure` | Spring Boot auto-configuration that wires the right backend based on `embabel.dice.store.type`, schedules the decay tick, and provides auto-configuration for the multi-signal duplicate collector (properties prefix `embabel.dice.collector`) |
| `dice-report` | Output projectors over propositions: rationale (why a fact is believed, with evidence), structured report, and surprising-link discovery |
| `dice-ingestion` | Ingestion SPI (artifacts → chunks) with a content-hash dedup ledger so the same source isn't extracted twice |
-| `dice-metamodel` | Schema versioning: `MetamodelVersion` content-hash stamps over the governed types of a `DataDictionary`, `GovernedTypeSelector`, the `DeclaredSchemaSource` opt-in seam, and the `MetamodelVersionStore` contract. Pure JVM, with no dependency on `dice` |
+| `dice-metamodel` | Schema versioning: `MetamodelVersion` content-hash stamps over the governed types of a `DataDictionary`, `GovernedTypeSelector`, the `DeclaredSchemaSource` opt-in, and the `MetamodelVersionStore` contract. Pure JVM, with no dependency on `dice` |
| `dice-integration-tests` | Test-only: the cross-feature end-to-end canonical-flow harness |
## Build & test
diff --git a/README.md b/README.md
index 365e45c3..fd9e296c 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ recover by reading a single class — see the design notes in [`docs/design/`](d
two-phase save, materialised effective confidence, schema-as-beans, and the decay tick.
- [Events](docs/design/events.md) — the domain-event model the store and pipeline emit.
- [Metamodel versioning](docs/design/metamodel-versioning.md) — content-hash schema stamps, per-type
- governance, the declared-schema opt-in seam, and version history.
+ governance, the declared-schema opt-in, and version history.
## Real-World Example: Impromptu
diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml
index d9d92f18..4fa5ec2a 100644
--- a/dice-metamodel/pom.xml
+++ b/dice-metamodel/pom.xml
@@ -10,7 +10,7 @@
dice-metamodel
jar
Dice Metamodel
- Schema versioning for DICE knowledge graphs: content-hash stamping, the declared-schema seam, and the version store contract
+ Schema versioning for DICE knowledge graphs: content-hash stamping, the declared-schema contract, and the version store contract
+
+ org.jetbrains
+ annotations
+ 26.0.2
+ provided
+
+
org.springframework.boot
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt
index 64afc9bd..469a6a46 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/DeclaredSchemaSource.kt
@@ -16,6 +16,7 @@
package com.embabel.dice.metamodel
import com.embabel.agent.core.DataDictionary
+import org.jetbrains.annotations.ApiStatus
/**
* The schema as declared: the stamped [version] plus the bare relationship type names it allows.
@@ -31,6 +32,7 @@ import com.embabel.agent.core.DataDictionary
* @property version The stamped declared schema.
* @property relationshipTypeNames The bare relationship type names [version] allows.
*/
+@ApiStatus.Experimental
class DeclaredSchema(
val version: MetamodelVersion,
relationshipTypeNames: Set,
@@ -116,6 +118,7 @@ class DeclaredSchema(
* already uses to define its types (a `DataDictionary`, a config file, a registry...) and wires it
* as a bean. There is no default implementation, because there is no default declared schema.
*/
+@ApiStatus.Experimental
fun interface DeclaredSchemaSource {
/**
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt
index 7acfc216..1c77ee48 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/GovernedTypeSelector.kt
@@ -16,6 +16,7 @@
package com.embabel.dice.metamodel
import com.embabel.agent.core.DomainType
+import org.jetbrains.annotations.ApiStatus
/**
* Decides which domain types a [MetamodelVersion] stamp covers.
@@ -37,6 +38,7 @@ import com.embabel.agent.core.DomainType
* ungoverned type to the dictionary leaves the content hash as it was, while touching a governed
* one changes it.
*/
+@ApiStatus.Experimental
fun interface GovernedTypeSelector {
/**
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
index 2a7b87d2..adb85fd9 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
@@ -20,6 +20,7 @@ import com.embabel.agent.core.DataDictionary
import com.embabel.agent.core.DomainTypePropertyDefinition
import com.embabel.agent.core.NamedPropertyDefinition
import com.embabel.agent.core.PropertyDefinition
+import org.jetbrains.annotations.ApiStatus
import java.security.MessageDigest
import java.util.Objects
@@ -48,6 +49,7 @@ import java.util.Objects
* takes a signature, and a signature that never reaches a stamp keeps whatever set it was built
* with. Experimental: shape may change before 1.0.
*/
+@ApiStatus.Experimental
data class PropertySignature @JvmOverloads constructor(
val name: String,
val kind: Kind,
@@ -153,6 +155,7 @@ data class PropertySignature @JvmOverloads constructor(
* restarts. Any structural change produces a different hash, including a property's type changing
* on a type whose name is unchanged.
*/
+@ApiStatus.Experimental
class MetamodelVersion @JvmOverloads constructor(
schemaName: String,
entityTypeNames: List,
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
index 33c8e0fd..ab8d9a0a 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
@@ -15,6 +15,8 @@
*/
package com.embabel.dice.metamodel
+import org.jetbrains.annotations.ApiStatus
+
/**
* Durable store for metamodel version stamps. Keeping every stamp a schema has ever had is what
* later lets you say when a shape changed and what knowledge was extracted under which version.
@@ -32,6 +34,7 @@ package com.embabel.dice.metamodel
* This contract covers stamping and recall. Comparing a declaration against a live graph is a
* separate concern with its own store contract.
*/
+@ApiStatus.Experimental
interface MetamodelVersionStore {
/**
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/SchemaAliases.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/SchemaAliases.kt
index f51ca047..28aea42c 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/SchemaAliases.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/SchemaAliases.kt
@@ -15,6 +15,8 @@
*/
package com.embabel.dice.metamodel
+import org.jetbrains.annotations.ApiStatus
+
/**
* The former names a schema's types and properties have gone by, declared alongside the schema
* itself.
@@ -42,6 +44,7 @@ package com.embabel.dice.metamodel
* @property propertyAliases Entity type name, then current property name, to the names that
* property used to have.
*/
+@ApiStatus.Experimental
class SchemaAliases @JvmOverloads constructor(
typeAliases: Map> = emptyMap(),
propertyAliases: Map>> = emptyMap(),
From 726a51d80057f3e1f221f4d5a7fdf14f4c9f54cd Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Wed, 2 Sep 2026 16:50:04 -0400
Subject: [PATCH 09/11] Name no consumer in the changelog header
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf419a7a..cd5fa989 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
Notable changes to DICE. Each entry states its compatibility impact on consumers
-(assistant/me and anything else tracking `0.2.0-SNAPSHOT`): **additive** (safe to
+(anything tracking `0.2.0-SNAPSHOT`): **additive** (safe to
pick up), **behavioral** (same API, different runtime behavior — read the note),
or **breaking** (consumer change required; the entry links the migration notes
and the consumer PRs that deliver it).
From 3c2fcda81f43244a86bef7eeb480925bc3da716e Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Thu, 3 Sep 2026 16:58:40 -0400
Subject: [PATCH 10/11] Answer the code review on the metamodel stamp
Manage org.jetbrains:annotations in dice-parent. Neither embabel BOM manages it, so each module pinned its own version and the two had already drifted apart. Simplify the alias comparator. Name the Java compatibility tests in sentences, which is what @DisplayName is for.
---
dice-metamodel/pom.xml | 1 -
.../com/embabel/dice/metamodel/MetamodelVersion.kt | 8 +++-----
.../dice/metamodel/MetamodelJavaCompatTest.java | 13 +++++++++++++
dice/pom.xml | 1 -
pom.xml | 11 +++++++++++
5 files changed, 27 insertions(+), 7 deletions(-)
diff --git a/dice-metamodel/pom.xml b/dice-metamodel/pom.xml
index b05e338f..67a95652 100644
--- a/dice-metamodel/pom.xml
+++ b/dice-metamodel/pom.xml
@@ -31,7 +31,6 @@
org.jetbrains
annotations
- 26.0.2
provided
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
index adb85fd9..954762c8 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
@@ -87,13 +87,11 @@ data class PropertySignature @JvmOverloads constructor(
/** Compare two alias sets as sorted lists: element by element, then by size. */
private fun compareAliases(left: Set, right: Set): Int {
- val sortedLeft = left.sorted()
- val sortedRight = right.sorted()
- for (i in 0 until minOf(sortedLeft.size, sortedRight.size)) {
- val comparison = sortedLeft[i].compareTo(sortedRight[i])
+ left.sorted().zip(right.sorted()).forEach { (leftAlias, rightAlias) ->
+ val comparison = leftAlias.compareTo(rightAlias)
if (comparison != 0) return comparison
}
- return sortedLeft.size.compareTo(sortedRight.size)
+ return left.size.compareTo(right.size)
}
/**
diff --git a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
index 8140f4f4..a2ac7a2d 100644
--- a/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
+++ b/dice-metamodel/src/test/java/com/embabel/dice/metamodel/MetamodelJavaCompatTest.java
@@ -19,6 +19,7 @@
import com.embabel.agent.core.DataDictionary;
import com.embabel.agent.core.DomainType;
import com.embabel.agent.core.DynamicType;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
@@ -51,6 +52,7 @@ private static DataDictionary goldenSchema() {
}
@Test
+ @DisplayName("the four-argument property signature constructor still exists")
void theFourArgumentPropertySignatureConstructorStillExists() {
PropertySignature signature = new PropertySignature(
"age", PropertySignature.Kind.VALUE, "string", Cardinality.ONE);
@@ -60,6 +62,7 @@ void theFourArgumentPropertySignatureConstructorStillExists() {
}
@Test
+ @DisplayName("the property signature constructor also takes aliases")
void thePropertySignatureConstructorAlsoTakesAliases() {
PropertySignature signature = new PropertySignature(
"emailAddress", PropertySignature.Kind.VALUE, "string", Cardinality.ONE, Set.of("email"));
@@ -68,6 +71,7 @@ void thePropertySignatureConstructorAlsoTakesAliases() {
}
@Test
+ @DisplayName("the five-argument metamodel version constructor still exists")
void theFiveArgumentMetamodelVersionConstructorStillExists() {
MetamodelVersion version = new MetamodelVersion(
"test",
@@ -81,6 +85,7 @@ void theFiveArgumentMetamodelVersionConstructorStillExists() {
}
@Test
+ @DisplayName("the metamodel version constructor also takes aliases")
void theMetamodelVersionConstructorAlsoTakesAliases() {
MetamodelVersion version = new MetamodelVersion(
"test",
@@ -94,6 +99,7 @@ void theMetamodelVersionConstructorAlsoTakesAliases() {
}
@Test
+ @DisplayName("the one and two-argument stamping factories still exist")
void theOneAndTwoArgumentStampingFactoriesStillExist() {
MetamodelVersion whole = MetamodelVersion.from(goldenSchema());
MetamodelVersion governed = MetamodelVersion.from(goldenSchema(), GovernedTypeSelector.ALL);
@@ -103,6 +109,7 @@ void theOneAndTwoArgumentStampingFactoriesStillExist() {
}
@Test
+ @DisplayName("the stamping factory also takes aliases")
void theStampingFactoryAlsoTakesAliases() {
MetamodelVersion version = MetamodelVersion.from(
goldenSchema(),
@@ -113,6 +120,7 @@ void theStampingFactoryAlsoTakesAliases() {
}
@Test
+ @DisplayName("the one and two-argument declaration factories still exist")
void theOneAndTwoArgumentDeclarationFactoriesStillExist() {
DeclaredSchema whole = DeclaredSchema.from(goldenSchema());
DeclaredSchema governed = DeclaredSchema.from(goldenSchema(), GovernedTypeSelector.ALL);
@@ -122,6 +130,7 @@ void theOneAndTwoArgumentDeclarationFactoriesStillExist() {
}
@Test
+ @DisplayName("the declaration factory also takes aliases")
void theDeclarationFactoryAlsoTakesAliases() {
DeclaredSchema declared = DeclaredSchema.from(
goldenSchema(),
@@ -132,12 +141,14 @@ void theDeclarationFactoryAlsoTakesAliases() {
}
@Test
+ @DisplayName("the no-argument alias constructor exists")
void theNoArgumentAliasConstructorExists() {
assertEquals(Map.of(), new SchemaAliases().getTypeAliases());
assertEquals(Map.of(), SchemaAliases.NONE.getPropertyAliases());
}
@Test
+ @DisplayName("the collections a stamp hands back refuse mutation from Java")
void theCollectionsAStampHandsBackRefuseMutationFromJava() {
MetamodelVersion version = MetamodelVersion.from(
goldenSchema(),
@@ -149,6 +160,7 @@ void theCollectionsAStampHandsBackRefuseMutationFromJava() {
}
@Test
+ @DisplayName("the shipped Kotlin default synthetic keeps its descriptor")
void theShippedKotlinDefaultSyntheticKeepsItsDescriptor() throws Exception {
// A Kotlin caller that omits a defaulted argument links against the $default synthetic
// rather than the function itself. DeclaredSchema.from shipped with one defaulted
@@ -168,6 +180,7 @@ void theShippedKotlinDefaultSyntheticKeepsItsDescriptor() throws Exception {
}
@Test
+ @DisplayName("the stamping factories take no defaulted parameters")
void theStampingFactoriesTakeNoDefaultedParameters() throws Exception {
// MetamodelVersion.from shipped as two overloads with no defaults, so it has no $default
// synthetic to preserve. Keeping it that way means the alias overload can never widen one.
diff --git a/dice/pom.xml b/dice/pom.xml
index 09616eca..ba1c7432 100644
--- a/dice/pom.xml
+++ b/dice/pom.xml
@@ -61,7 +61,6 @@
org.jetbrains
annotations
- 26.0.1
1.41
+
+ 26.0.2
@@ -94,6 +99,12 @@
drivine4j-spring-boot-starter
${drivine.version}
+
+
+ org.jetbrains
+ annotations
+ ${jetbrains.annotations.version}
+
From e423a0ab2b7dc05d6c70d63ac9ebcb6c1258633d Mon Sep 17 00:00:00 2001
From: James Dunnam <7660553+jimador@users.noreply.github.com>
Date: Thu, 3 Sep 2026 23:10:22 -0400
Subject: [PATCH 11/11] Answer the agent findings on the metamodel stamp
Cross-reference equals and hasSameContentAs, since one compares the schema name and the other does not. Run the two declaration refusals through one requireDeclarable so the constructor and from cannot drift. Group governed types by name once. Say why the store key carries the schema name: two schemas with the same shape share a hash, and history is per schema. Say when findVersion's default is enough and when to override it. A design note explains why three classes write their own equals.
---
.../dice/metamodel/MetamodelVersion.kt | 73 ++++++++++++-------
.../dice/metamodel/MetamodelVersionStore.kt | 16 +++-
docs/design/metamodel-versioning.md | 21 ++++++
3 files changed, 81 insertions(+), 29 deletions(-)
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
index 954762c8..aa9170eb 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersion.kt
@@ -206,8 +206,7 @@ class MetamodelVersion @JvmOverloads constructor(
"Drop the entry instead; it means the same thing and hashes the same as the types around it."
}
- requireNoTypeAliasReuse(known, this.entityTypeAliases)
- requireNoAliasesOnDuplicateNames(this.entityTypeProperties)
+ requireDeclarable(known, this.entityTypeAliases, this.entityTypeProperties)
}
val contentHash: String = fingerprint()
@@ -216,6 +215,9 @@ class MetamodelVersion @JvmOverloads constructor(
* Returns `true` when this version and [other] have the same structural content (entity types,
* label sets, property signatures, and relationships), regardless of schema name. Compares
* [contentHash].
+ *
+ * [equals] also compares [schemaName]; this does not. Use this to ask whether two schemas have
+ * the same shape, and [equals] to ask whether two stamps are the same stamp.
*/
fun hasSameContentAs(other: MetamodelVersion): Boolean = contentHash == other.contentHash
@@ -266,7 +268,14 @@ class MetamodelVersion @JvmOverloads constructor(
return hashBytes.joinToString("") { "%02x".format(it) }
}
- /** Structural equality: same schema name, types, labels, property signatures, relationships, and aliases. */
+ /**
+ * Structural equality: same schema name, types, labels, property signatures, relationships, and
+ * aliases.
+ *
+ * This compares [schemaName]; [hasSameContentAs] and [contentHash] leave it out. Two stamps of
+ * identically shaped schemas under different names are therefore unequal here and equal there,
+ * and a `Set` keys the way the store does, on schema and content both.
+ */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MetamodelVersion) return false
@@ -331,6 +340,21 @@ class MetamodelVersion @JvmOverloads constructor(
private fun withImmutableAliases(signature: PropertySignature): PropertySignature =
signature.copy(aliases = java.util.Set.copyOf(signature.aliases))
+ /**
+ * The refusals a declaration has to pass, run as one so the constructor and [from] can't
+ * drift on which checks apply. [from] runs it first, so a declaration that can't be stamped
+ * fails at the call that stamped it; the constructor runs it again, so a stamp built by
+ * hand meets the same guard.
+ */
+ private fun requireDeclarable(
+ declaredTypeNames: Set,
+ entityTypeAliases: Map>,
+ entityTypeProperties: Map>,
+ ) {
+ requireNoTypeAliasReuse(declaredTypeNames, entityTypeAliases)
+ requireNoAliasesOnDuplicateNames(entityTypeProperties)
+ }
+
/**
* Reject a declared type name showing up in another type's alias set.
*
@@ -449,28 +473,26 @@ class MetamodelVersion @JvmOverloads constructor(
// A DataDictionary can legally hold two domain types that share a name but differ in
// shape (DynamicType is a data class, so same-named instances with different labels are
- // not equal and both survive a set). Labels and properties are unioned per name.
- // Keeping only the last would drop a label or property from the fingerprint, and
- // removing it later wouldn't change the hash.
- val entityTypeLabels = governedTypes
- .groupBy { it.name }
- .mapValues { (_, types) -> types.flatMap { it.labels }.toSet() }
+ // not equal and both survive a set). Labels and properties are unioned per name, off
+ // the one grouping. Keeping only the last would drop a label or property from the
+ // fingerprint, and removing it later wouldn't change the hash.
+ val typesByName = governedTypes.groupBy { it.name }
+
+ val entityTypeLabels = typesByName.mapValues { (_, types) -> types.flatMap { it.labels }.toSet() }
// Decorating inside this loop is the only place it can happen: the stamp is immutable
// and hashes at construction. Every signature sharing a property name picks up the same
// declared alias set, so decoration can neither create nor collapse a duplicate, and
// the constructor's duplicate-name guard sees exactly the duplicates the union holds.
- val entityTypeProperties = governedTypes
- .groupBy { it.name }
- .mapValues { (typeName, types) ->
- types.flatMap { type ->
- type.properties.map { property ->
- val signature = PropertySignature.of(property)
- val declared = aliases.propertyAliasesFor(typeName, signature.name)
- if (declared.isEmpty()) signature else signature.copy(aliases = declared)
- }
- }.toSet()
- }
+ val entityTypeProperties = typesByName.mapValues { (typeName, types) ->
+ types.flatMap { type ->
+ type.properties.map { property ->
+ val signature = PropertySignature.of(property)
+ val declared = aliases.propertyAliasesFor(typeName, signature.name)
+ if (declared.isEmpty()) signature else signature.copy(aliases = declared)
+ }
+ }.toSet()
+ }
// Splitting one type into two same-named declarations, or merging two back into one,
// can render the same relationship descriptor twice. It is the same schema either way,
@@ -479,17 +501,16 @@ class MetamodelVersion @JvmOverloads constructor(
.filter { selector.governs(it.from) }
.map { rel -> "${rel.from.name}-[${rel.name}]->${rel.to.name}" }
- val governedNames = governedTypes.map { it.name }.toSet()
+ val governedNames = typesByName.keys
val entityTypeAliases = aliases.typeAliases.filterKeys { it in governedNames }
- // The two refusals run here as well as in the constructor so a declaration that can't
- // be stamped fails at the call that stamped it, naming the alias to retire.
- requireNoTypeAliasReuse(governedNames, entityTypeAliases)
- requireNoAliasesOnDuplicateNames(entityTypeProperties)
+ // Runs here as well as in the constructor so a declaration that can't be stamped fails
+ // at the call that stamped it, naming the alias to retire.
+ requireDeclarable(governedNames, entityTypeAliases, entityTypeProperties)
return MetamodelVersion(
schemaName = dataDictionary.name,
- entityTypeNames = governedTypes.map { it.name },
+ entityTypeNames = governedNames.toList(),
entityTypeLabels = entityTypeLabels,
entityTypeProperties = entityTypeProperties,
relationshipNames = relationshipNames,
diff --git a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
index ab8d9a0a..62e06587 100644
--- a/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
+++ b/dice-metamodel/src/main/kotlin/com/embabel/dice/metamodel/MetamodelVersionStore.kt
@@ -31,6 +31,14 @@ import org.jetbrains.annotations.ApiStatus
* is deleted, and records with different keys always coexist, so history accumulates.
* Implementations are not expected to reject a re-save.
*
+ * **Why the schema name is in the key.** [MetamodelVersion.contentHash] excludes the schema name
+ * on purpose, so two schemas with the same shape share a hash: a schema and its staging copy, or a
+ * schema forked under a new name. History is per schema, which is what [latestVersion] and
+ * [versionHistory] answer, so the same content has to be a separate record under each name.
+ * Keyed on the hash alone, one schema adopting a shape another had earlier would land on the other
+ * schema's record and pull that schema's history into its own. The name in the key is what keeps
+ * two schemas' histories from bleeding into each other.
+ *
* This contract covers stamping and recall. Comparing a declaration against a live graph is a
* separate concern with its own store contract.
*/
@@ -71,9 +79,11 @@ interface MetamodelVersionStore {
* Resolves a recorded hash, such as the one a proposition carries as the version it was
* extracted under, back into the schema shape it stood for.
*
- * The default scans [versionHistory], which is correct for any implementation but reads the
- * whole history to answer a keyed question. A backend that can push the lookup down to the
- * database (a keyed `MATCH` rather than an in-memory `filter`) should override it.
+ * The default scans [versionHistory], which is correct for any implementation and reads the
+ * whole history to answer a keyed question. That is fine for the in-memory reference and for
+ * a test double. A durable backend should override it with a keyed lookup, since a long-lived
+ * schema's history only grows and this default grows with it; the Drivine store does, with a
+ * `MATCH` on the natural key.
*
* @param schemaName The schema the version belongs to.
* @param contentHash The [MetamodelVersion.contentHash] to find.
diff --git a/docs/design/metamodel-versioning.md b/docs/design/metamodel-versioning.md
index f99e9d6f..b5ff7b86 100644
--- a/docs/design/metamodel-versioning.md
+++ b/docs/design/metamodel-versioning.md
@@ -336,6 +336,27 @@ keyed question; a backend that can push the lookup down to the database should o
module ships no implementation. Storage is a separate concern, and a stamp is useful in memory
before anything durable exists.
+## Plain classes, not data classes
+
+`MetamodelVersion`, `DeclaredSchema` and `SchemaAliases` each write their own `equals`, `hashCode`
+and `toString`. That is one deliberate pattern, for one reason: each of them copies what the
+constructor is handed into a JVM-immutable collection in its body, and a `data class` cannot do
+that. A constructor `val` takes no initialiser, so the generated `equals` and `copy` would read the
+raw arguments and skip the copy, and a stamp whose collections could still be changed from the
+outside would disagree with its own precomputed hash.
+
+`PropertySignature` is the exception that proves it. It is a `data class`, and its `aliases` set is
+therefore held as handed in; `MetamodelVersion` copies that set into an immutable one when it takes
+a signature. The KDoc on each class says as much, and this section is here so the pattern is read
+as a module decision and not raised class by class.
+
+Two notions of equality live on `MetamodelVersion`, and both are meant. `equals` compares the schema
+name along with the content, so a `Set` keys the way the store does. `contentHash`
+and `hasSameContentAs` leave the name out, so two schemas with the same shape under different names
+compare equal there. That is also why the store's natural key is `(schemaName, contentHash)` and not
+the hash alone: history is per schema, and one schema adopting a shape another had earlier must not
+land on the other schema's record.
+
## The tiers ahead
Versioning is the first of three escalating tiers, shipped in that order.