diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs
new file mode 100644
index 0000000000..8099aedfb4
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/CrossLinks/LinksJsonForwardCompatibilityTests.cs
@@ -0,0 +1,129 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Collections.Frozen;
+using AwesomeAssertions;
+using Elastic.Documentation.Links;
+using Elastic.Documentation.Links.CrossLinks;
+
+namespace Elastic.Markdown.Tests.CrossLinks;
+
+///
+/// links.json is published by one build and read by every other build, including builds of older versions of
+/// docs-builder. A newer build can add properties, such as the pages of an API. An older reader must ignore what it
+/// does not know and read everything else exactly as before.
+///
+public class LinksJsonForwardCompatibilityTests
+{
+ private const string Core =
+ // language=json
+ """
+ "origin": { "branch": "main", "remote": "https://github.com/elastic/docs-content", "ref": "76aac68d066e2af935c38bca8ce04d3ee67a8dd9" },
+ "url_path_prefix": "/elastic/docs-content/tree/main",
+ "cross_links": [ "kibana://index.md" ],
+ "links": {
+ "index.md": {},
+ "get-started/index.md": { "anchors": [ "intro", "use-cases" ] },
+ "hidden-page.md": { "hidden": true }
+ },
+ "redirects": { "old.md": { "to": "get-started/index.md" } }
+ """;
+
+ private static string WithProperty(string property) => "{" + Core + "," + property + "}";
+
+ /// The section a later build writes for an API: each version states its root, and each page names the versions that contain it.
+ private const string FloatingSection =
+ // language=json
+ """
+ "floating": {
+ "api/elasticsearch": {
+ "versions": [
+ { "name": "8", "root": "api/doc/elasticsearch/v8", "hidden": true },
+ { "name": "main", "root": "api/doc/elasticsearch", "current": true }
+ ],
+ "pages": { "": 3, "operation/operation-search": 3, "operation/operation-new": 2 },
+ "aliases": { "8": { "operation/operation-search": [ "operation/operation-search-1" ] } }
+ }
+ }
+ """;
+
+ public static IEnumerable FutureProperties() =>
+ [
+ FloatingSection,
+ // language=json
+ "\"floating\": \"a string\"",
+ // language=json
+ "\"floating\": 42",
+ // language=json
+ "\"floating\": [ 1, 2, { \"nested\": [ true, null ] } ]",
+ // language=json
+ "\"floating\": { \"api/elasticsearch\": { \"versions\": [ { \"name\": \"main\", \"unheard_of\": [ 1 ] } ], \"something_new\": {} } }",
+ // language=json
+ "\"some_other_future_property\": { \"a\": 1 }"
+ ];
+
+ private static void AssertReadsTheDocumentedProperties(RepositoryLinks links)
+ {
+ links.Origin.Branch.Should().Be("main");
+ links.UrlPathPrefix.Should().Be("/elastic/docs-content/tree/main");
+ links.CrossLinks.Should().Equal("kibana://index.md");
+ links.Links.Keys.Should().BeEquivalentTo("index.md", "get-started/index.md", "hidden-page.md");
+ links.Links["get-started/index.md"].Anchors.Should().Equal("intro", "use-cases");
+ links.Links["hidden-page.md"].Hidden.Should().BeTrue();
+ links.Redirects.Should().ContainKey("old.md").WhoseValue.To.Should().Be("get-started/index.md");
+ }
+
+ [Test]
+ [MethodDataSource(nameof(FutureProperties))]
+ public void Deserialize_WithAPropertyAnOlderReaderDoesNotKnow_ReadsTheRestUnchanged(string property) =>
+ AssertReadsTheDocumentedProperties(RepositoryLinks.Deserialize(WithProperty(property)));
+
+ [Test]
+ public void Deserialize_WithAndWithoutAFloatingSection_GivesTheSameLinks()
+ {
+ var without = RepositoryLinks.Deserialize("{" + Core + "}");
+ var with = RepositoryLinks.Deserialize(WithProperty(FloatingSection));
+
+ with.Links.Should().BeEquivalentTo(without.Links);
+ with.Redirects.Should().BeEquivalentTo(without.Redirects);
+ with.CrossLinks.Should().Equal(without.CrossLinks);
+ with.Origin.Should().Be(without.Origin);
+ }
+
+ [Test]
+ public void CrossLinkFetcher_Deserialize_WithAFloatingSection_ReadsTheRest() =>
+ AssertReadsTheDocumentedProperties(CrossLinkFetcher.Deserialize(WithProperty(FloatingSection)));
+
+ [Test]
+ [Arguments("docs-content://index.md", true)]
+ [Arguments("docs-content://get-started/index.md#intro", true)]
+ [Arguments("docs-content://get-started/index.md#missing-anchor", false)]
+ [Arguments("docs-content://does-not-exist.md", false)]
+ public void CrossLinks_WithAFloatingSectionInTheTargetLinks_ResolveAsBefore(string crossLink, bool expected)
+ {
+ var resolve = (RepositoryLinks links) =>
+ {
+ var fetched = new FetchedCrossLinks
+ {
+ DeclaredRepositories = ["docs-content"],
+ LinkReferences = new Dictionary { ["docs-content"] = links }.ToFrozenDictionary(),
+ LinkIndexEntries = new Dictionary().ToFrozenDictionary()
+ };
+ var resolved = CrossLinkResolver.TryResolve(
+ _ => { },
+ fetched,
+ new IsolatedBuildEnvironmentUriResolver(),
+ new Uri(crossLink),
+ out var uri
+ );
+ return (resolved, uri?.ToString());
+ };
+
+ var without = resolve(RepositoryLinks.Deserialize("{" + Core + "}"));
+ var with = resolve(RepositoryLinks.Deserialize(WithProperty(FloatingSection)));
+
+ with.resolved.Should().Be(expected);
+ with.Should().Be(without, "a floating section changes nothing for pages that are not part of it");
+ }
+}