Repository: equinor/fmu-dataio

Issue:
Create a documentation protocol/changelog format for schema changes
We should have very detailed documentation tracking the changes to the schema on a version basis. 

On any given day there could be uploads from 3 or more different Komodo versions spanning a year or longer apart. Being able to associate the presence or non-presence of schema fields will be important to point both users and consumers in the right direction for features that may become available on the consumer-side after some metadata field becomes present in the data.

Discussion:

Solution patch:
diff --git a/docs/src/conf.py b/docs/src/conf.py
index 292b1e57a..da53d9ca5 100755
--- a/docs/src/conf.py
+++ b/docs/src/conf.py
@@ -51,10 +51,26 @@ myst_enable_extensions = [
 ]
 
 
-def _myst_substitutions() -> dict[str, SchemaBase]:
+def _myst_substitutions() -> dict[str, type[SchemaBase]]:
+    """A list of substitutions usable in the documentation.
+
+    Giving
+
+        subs[f"{s.__name__}"] = s
+
+    where `s` is a class allows you to access class values from within MyST markdown
+    documentation like:
+
+        FmuResultsSchema.VERSION
+
+    Any arbitrary key-value pairs from the code are possible to template into the
+    documentation in this way.
+    """
     subs = {}
     for s in schemas:
-        s.literalinclude = dedent(f"""
+        # For templating an include of the JSON Schema
+        s.literalinclude = dedent(  # type: ignore
+            f"""
             ```{{eval-rst}}
                .. toggle::
 
@@ -62,9 +78,14 @@ def _myst_substitutions() -> dict[str, SchemaBase]:
                      :language: json
 
             ```
-        """)
+        """
+        )
         if hasattr(s, "CONTRACTUAL"):
-            s.contractual = "\n".join([f"- `{item}`" for item in s.CONTRACTUAL])
+            s.contractual = "\n".join(  # type: ignore
+                [f"- `{item}`" for item in s.CONTRACTUAL]
+            )
+        # Ensure changelogs aren't rendered as code blocks
+        s.VERSION_CHANGELOG = dedent(s.VERSION_CHANGELOG)
         subs[f"{s.__name__}"] = s
     return subs
 
diff --git a/docs/src/datamodel/index.md b/docs/src/datamodel/index.md
index ded0d1ac8..c934693dd 100644
--- a/docs/src/datamodel/index.md
+++ b/docs/src/datamodel/index.md
@@ -313,6 +313,9 @@ To handle this, two important concepts has been introduced.
    should not change erratically, and when we need to change them, this needs to
    be subject to alignment.
 
+### Schema version changelog
+
+{{ FmuResultsSchema.VERSION_CHANGELOG }}
 
 ### Contractual attributes
 
diff --git a/docs/src/standard_results/initial_inplace_volumes.md b/docs/src/standard_results/initial_inplace_volumes.md
index 9e4d7235e..0ef8ecdb0 100644
--- a/docs/src/standard_results/initial_inplace_volumes.md
+++ b/docs/src/standard_results/initial_inplace_volumes.md
@@ -80,6 +80,10 @@ present within the `data.standard_result` key in its associated object metadata.
 | Prod URL | {{ '[{}]({}) 🔒'.format(InplaceVolumesSchema.prod_url(), InplaceVolumesSchema.prod_url()) }}
 | Dev URL | {{ '[{}]({}) 🔒'.format(InplaceVolumesSchema.dev_url(), InplaceVolumesSchema.dev_url()) }}
 
+### Changelog
+
+{{ InplaceVolumesSchema.VERSION_CHANGELOG }}
+
 ### JSON schema
 
 The current JSON schema is embedded here.
diff --git a/schemas/0.9.0/fmu_results.json b/schemas/0.9.0/fmu_results.json
index a50104b77..73e210bf8 100644
--- a/schemas/0.9.0/fmu_results.json
+++ b/schemas/0.9.0/fmu_results.json
@@ -16,7 +16,6 @@
     "data.spec.columns",
     "data.standard_result.name",
     "data.stratigraphic",
-    "data.stratigraphic_alias",
     "data.tagname",
     "data.time",
     "data.vertical_domain",
diff --git a/src/fmu/dataio/_models/_schema_base.py b/src/fmu/dataio/_models/_schema_base.py
index 1c147f6f2..17a267873 100644
--- a/src/fmu/dataio/_models/_schema_base.py
+++ b/src/fmu/dataio/_models/_schema_base.py
@@ -105,6 +105,9 @@ class SchemaBase(ABC):
     VERSION: VersionStr
     """The current version of the schema."""
 
+    VERSION_CHANGELOG: str
+    """The changelog for all versions of schemas."""
+
     FILENAME: str
     """The filename, i.e. schema.json."""
 
@@ -130,16 +133,27 @@ class SchemaBase(ABC):
         implemented. It also doesn't like the default generator.
         """
         super().__init_subclass__(**kwargs)
-        for attr in ("VERSION", "FILENAME", "PATH"):
+        cls._validate_class_vars_set()
+        cls._validate_version()
+        cls._validate_version_changelog()
+        cls._validate_path()
+
+    @classmethod
+    def _validate_class_vars_set(cls) -> None:
+        for attr in ("VERSION", "VERSION_CHANGELOG", "FILENAME", "PATH"):
             if not hasattr(cls, attr):
                 raise TypeError(f"Subclass {cls.__name__} must define '{attr}'")
 
+    @classmethod
+    def _validate_path(cls) -> None:
         if not cls.PATH.parts[0].startswith(str(FmuSchemas.PATH)):
             raise ValueError(
                 f"PATH must start with `FmuSchemas.PATH`: {FmuSchemas.PATH}. "
                 f"Got {cls.PATH}"
             )
 
+    @classmethod
+    def _validate_version(cls) -> None:
         try:
 
             class PydanticVersionValidator(BaseModel):
@@ -151,6 +165,18 @@ class SchemaBase(ABC):
         except ValidationError as e:
             raise TypeError(f"Invalid VERSION format for '{cls.__name__}': {e}") from e
 
+    @classmethod
+    def _validate_version_changelog(cls) -> None:
+        """Ensures that VERSION has a doc string that looks like a changelog.
+
+        Checks that the current version has an entry as well."""
+        if f"### {cls.VERSION}" not in cls.VERSION_CHANGELOG:
+            raise ValueError(
+                f"No changelog entry exists for '{cls.__name__}' version {cls.VERSION}."
+                f" Expected a header like '### {cls.VERSION}' in 'VERSION_CHANGELOG' "
+                f"with changes for version {cls.VERSION} listed beneath it."
+            )
+
     @classmethod
     def dev_url(cls) -> str:
         """Returns the url to the schema on the Radix dev environment."""
diff --git a/src/fmu/dataio/_models/fmu_results/fmu_results.py b/src/fmu/dataio/_models/fmu_results/fmu_results.py
index 4e63578f0..df92ed7f4 100644
--- a/src/fmu/dataio/_models/fmu_results/fmu_results.py
+++ b/src/fmu/dataio/_models/fmu_results/fmu_results.py
@@ -45,6 +45,46 @@ class FmuResultsSchema(SchemaBase):
     """The main metadata export describing the results."""
 
     VERSION: VersionStr = "0.9.0"
+
+    VERSION_CHANGELOG: str = """
+    #### 0.9.0
+
+    This is the first versioned update to the schema and contains numerous changes.
+
+    - `$contractual.stratigraphic_alias` has been removed. It was never used.
+    - `data.product`: renamed to `data.standard_result`
+    - `data.spec.nrow` must be greater or equal to 0 for cubes, surfaces
+    - `data.spec.ncol` must be greater or equal to 0 for cubes, surfaces
+    - `data.spec.nlay` must be greater or equal to 0 for cubes
+    - `data.spec.xinc` must be greater or equal to 0 for cubes, surfaces
+    - `data.spec.yinc` must be greater or equal to 0 for cubes, surfaces
+    - `data.spec.zinc` must be greater or equal to 0 for cubes
+    - `data.spec.npolys` must be greater or equal to 0 for polygons
+    - `data.spec.num_columns` is no longer optional and must be greater or equal
+      to 0 for tables
+    - `data.spec.num_rows` is no longer optional and must be greater or equal to
+      0 for tables
+    - `data.spec.size` must be greater or equal 0 for tables, points
+    - `data.time.t0` is no longer optional
+    - `data.time.t0.value` is no longer optional
+    - `data.time.t1.value` is no longer optional (`data.time.t1` remains optional)
+    - `data.stratigraphic_alias` has been removed
+    - `file.absolute_path_symlink` has been removed
+    - `file.relative_path_symlink` has been removed
+    - `fmu.aggregation.parameters` has been removed
+    - `fmu.ert.experiment` is no longer optional
+    - `fmu.ert.experiment.id` is no longer optional
+    - `fmu.ert.simulation_mode` is no longer optional
+    - `fmu.iteration.id` is no longer optional and must be greater or equal to 0
+    - `fmu.realization.id` must be greater or equal to 0
+    - `fmu.realization.parameters` has been removed
+    - `fmu.realization.jobs` has been removed
+
+    #### 0.8.0
+
+    This is the initial schema version.
+    """
+
     FILENAME: str = "fmu_results.json"
     PATH: Path = FmuSchemas.PATH / VERSION / FILENAME
 
@@ -66,7 +106,6 @@ class FmuResultsSchema(SchemaBase):
         "data.spec.columns",
         "data.standard_result.name",
         "data.stratigraphic",
-        "data.stratigraphic_alias",
         "data.tagname",
         "data.time",
         "data.vertical_domain",
diff --git a/src/fmu/dataio/_models/standard_results/inplace_volumes.py b/src/fmu/dataio/_models/standard_results/inplace_volumes.py
index 33ad304c7..35f279845 100644
--- a/src/fmu/dataio/_models/standard_results/inplace_volumes.py
+++ b/src/fmu/dataio/_models/standard_results/inplace_volumes.py
@@ -80,7 +80,12 @@ class InplaceVolumesSchema(SchemaBase):
     documented above."""
 
     VERSION: VersionStr = "0.1.0"
-    """The version of this schema."""
+
+    VERSION_CHANGELOG: str = """
+    #### 0.1.0
+
+    This is the initial schema version.
+    """
 
     FILENAME: str = "inplace_volumes.json"
     """The filename this schema is written to."""


Test patch:
diff --git a/tests/test_schema/test_schemabase.py b/tests/test_schema/test_schemabase.py
index 7679c8524..1b2c38ea7 100644
--- a/tests/test_schema/test_schemabase.py
+++ b/tests/test_schema/test_schemabase.py
@@ -13,26 +13,38 @@ def test_schemabase_validates_class_vars() -> None:
 
         class A(SchemaBase):
             VERSION: str = "0.8.0"
+            VERSION_CHANGELOG: str = "### 0.8.0"
             FILENAME: str = "fmu_results.json"
 
     with pytest.raises(TypeError, match="Subclass B must define 'FILENAME'"):
 
         class B(SchemaBase):
             VERSION: str = "0.8.0"
+            VERSION_CHANGELOG: str = "### 0.8.0"
             PATH: Path = FmuSchemas.PATH / "test"
 
     with pytest.raises(TypeError, match="Subclass C must define 'VERSION'"):
 
         class C(SchemaBase):
+            VERSION_CHANGELOG: str = "### 0.8.0"
             FILENAME: str = "fmu_results.json"
             PATH: Path = FmuSchemas.PATH / "test"
 
+    with pytest.raises(TypeError, match="Subclass D must define 'VERSION_CHANGELOG'"):
 
-def test_schemabase_validates_verion_string_form() -> None:
+        class D(SchemaBase):
+            VERSION: str = "0.8.0"
+            FILENAME: str = "fmu_results.json"
+            PATH: Path = FmuSchemas.PATH / "test"
+
+
+def test_schemabase_validates_version_string_form() -> None:
+    """Tests that the VERSION given to the schema raises if not of a valid form."""
     with pytest.raises(TypeError, match="Invalid VERSION format for 'MajorMinor'"):
 
         class MajorMinor(SchemaBase):
             VERSION = "12.3"
+            VERSION_CHANGELOG: str = "### 12.3"
             FILENAME: str = "fmu_results.json"
             PATH: Path = FmuSchemas.PATH / "test"
 
@@ -40,6 +52,7 @@ def test_schemabase_validates_verion_string_form() -> None:
 
         class Alphanumeric(SchemaBase):
             VERSION = "1.3.a"
+            VERSION_CHANGELOG: str = "### 1.3.a"
             FILENAME: str = "fmu_results.json"
             PATH: Path = FmuSchemas.PATH / "test"
 
@@ -47,6 +60,7 @@ def test_schemabase_validates_verion_string_form() -> None:
 
         class LeadingZero(SchemaBase):
             VERSION = "01.3.0"
+            VERSION_CHANGELOG: str = "### 01.3.0"
             FILENAME: str = "fmu_results.json"
             PATH: Path = FmuSchemas.PATH / "test"
 
@@ -60,5 +74,26 @@ def test_schemabase_requires_path_starting_with_fmuschemas_path() -> None:
 
         class A(SchemaBase):
             VERSION: str = "0.8.0"
+            VERSION_CHANGELOG: str = "### 0.8.0"
             FILENAME: str = "fmu_results.json"
             PATH: Path = Path("test")
+
+
+def test_schemabase_validates_a_version_has_a_changelog_entry() -> None:
+    """Tests that a version change has a corresponding changelog entry."""
+    with pytest.raises(
+        ValueError, match="No changelog entry exists for 'A' version 0.9.0"
+    ):
+
+        class A(SchemaBase):
+            VERSION: str = "0.9.0"
+            VERSION_CHANGELOG: str = ""
+            FILENAME: str = "fmu_results.json"
+            PATH: Path = FmuSchemas.PATH / "test"
+
+    # Should not raise ValueError
+    class B(SchemaBase):
+        VERSION: str = "0.9.0"
+        VERSION_CHANGELOG: str = "### 0.9.0"
+        FILENAME: str = "fmu_results.json"
+        PATH: Path = FmuSchemas.PATH / "test"