diff --git a/Directory.Packages.props b/Directory.Packages.props
index 475ea06..25bae83 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -36,7 +36,7 @@
-
+
diff --git a/External/NexusKit.Modules.Sync/ContractResolution.cs b/External/NexusKit.Modules.Sync/ContractResolution.cs
index b54c27f..61a45f9 100644
--- a/External/NexusKit.Modules.Sync/ContractResolution.cs
+++ b/External/NexusKit.Modules.Sync/ContractResolution.cs
@@ -9,23 +9,42 @@ namespace NexusKit.Modules.Sync;
public static class ContractResolution
{
///
- /// Fetches the server's contract when the key may read it, and falls back to the local
- /// document when it may not.
- /// The server's copy wins. A key carrying the built-in contract-reading scope
- /// is a statement that this client is allowed to follow the server's schema, and the
- /// server is where a contract is registered — so its version is authoritative, and a
- /// local copy that has drifted stops being a problem to diagnose.
- /// Without that scope the server does not hand out documents at all, and the client
- /// must already know the contract. That is the deliberate posture: a server should not
- /// describe what it holds to anyone who asks.
+ /// Picks the highest version the server offers that this client can actually speak, and stays
+ /// where it is when none of them will do.
+ /// The server leads, but it does not drag. A key carrying the built-in
+ /// contract-reading scope is a statement that this client may follow the server's schema — not
+ /// that it can follow it anywhere. Taking the newest document unread works right up until the
+ /// version that removes a field this client reads, at which point the client has agreed to a
+ /// document it cannot honour. So each offered version is measured against what this client was
+ /// built for, newest first, and the first one it can carry wins.
+ /// Falling behind is a normal outcome, not a failure: the previous version keeps working,
+ /// which is the entire reason old minors stay registered.
+ /// says what it cost. Callers are expected to surface that — this returns the reason
+ /// rather than logging it, because the client owns the log and a silent downgrade is the one
+ /// outcome nobody can diagnose later.
+ /// Without the scope the server does not hand out documents at all, and the client must
+ /// already know the contract. That is the deliberate posture: a server should not describe what
+ /// it holds to anyone who asks.
///
/// The connection to ask.
///
- /// What the client shipped with. Used when the server refuses; may be null, in which case
- /// a refusal is fatal — there would be nothing left to talk about.
+ /// What the client shipped with, and the yardstick every offered version is held against. Used
+ /// as-is when the server refuses; may be null, in which case a refusal is fatal — there would be
+ /// nothing left to talk about — and there is nothing to preserve, so the server's version is
+ /// taken unexamined.
///
/// Which contract, when is null.
- /// Which version to ask for.
+ ///
+ /// Which version to ask for. Its major bounds the search: crossing a major needs a
+ /// rebuilt client, not a negotiation.
+ ///
+ ///
+ /// The client's bare scopes from the handshake, deciding which collections it reads and which it
+ /// writes — and therefore what can break it. Null falls back to every scope
+ /// declares, which is safe but pessimistic: the client is then held
+ /// back over collections its key may not even touch. Prefer handshaking first and passing the
+ /// real set.
+ ///
/// Cancels the lookup.
///
/// The server refused and there is no local document to fall back on.
@@ -35,36 +54,99 @@ public static async Task ResolveAsync(
SyncContract? local,
string contractId,
ContractVersion version,
+ IReadOnlySet? grantedScopes,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(protocol);
+ var id = local?.ContractId ?? contractId;
+
try
{
var descriptor = await protocol
- .DescribeAsync(new ContractRef(local?.ContractId ?? contractId, version), ct)
+ .DescribeAsync(new ContractRef(id, version), ct)
.ConfigureAwait(false);
- return new ResolvedContract(ContractJson.Parse(descriptor.CanonicalJson), FromServer: true);
+ // Nothing to preserve, so nothing to weigh. A client with no document of its own has
+ // no expectations that the newest version could violate.
+ if (local is null)
+ return new ResolvedContract(descriptor.ToContract(), FromServer: true, descriptor.Version, []);
+
+ var scopes = grantedScopes ?? new HashSet(ContractScopes.All(local), StringComparer.Ordinal);
+
+ // Descending, so the search stops at the first version that fits rather than examining
+ // every one. The opening request bought the list of versions and the document for the
+ // one we asked about — Describe returns the exact version requested, it does not
+ // negotiate — so a client already on the newest pays one request and a client that has
+ // to move pays two.
+ var candidates = descriptor.AvailableVersions
+ .Where(v => v.Major == version.Major)
+ .OrderByDescending(v => v)
+ .ToArray();
+
+ ContractVersion? highest = candidates.Length > 0 ? candidates[0] : null;
+ var rejected = new List();
+
+ foreach (var candidate in candidates)
+ {
+ var document = candidate == descriptor.Version
+ ? descriptor.ToContract()
+ : (await protocol.DescribeAsync(new ContractRef(id, candidate), ct).ConfigureAwait(false))
+ .ToContract();
+
+ var support = ClientSupport.Evaluate(local, document, scopes);
+
+ if (support.IsSupported)
+ return new ResolvedContract(document, FromServer: true, highest, rejected);
+
+ // Prefixed, because "field 'source_id' is required" without a version is a puzzle
+ // and "1.2: field 'source_id' is required" is the answer to "why am I still on 1.1".
+ foreach (var blocker in support.Blockers) rejected.Add($"{candidate}: {blocker}");
+ }
+
+ // Every offered version costs this client something, so it keeps what it has. Its own
+ // document still matches whatever minor the handshake settles on, because old minors
+ // stay registered.
+ return new ResolvedContract(local, FromServer: false, highest, rejected);
}
catch (SyncProtocolException ex)
when (ex.Problem.Type is SyncProblemType.ScopeMissing or SyncProblemType.Unauthenticated)
{
// Not an error: this key is not permitted to read documents, which is the mode
- // where the client is expected to carry its own.
+ // where the client is expected to carry its own. No reading means no choosing.
if (local is null) throw;
- return new ResolvedContract(local, FromServer: false);
+ return new ResolvedContract(local, FromServer: false, HighestOffered: null, []);
}
}
}
///
-/// The contract to work from, and where it came from.
+/// The contract to work from, where it came from, and what choosing it cost.
///
/// The document both sides will be held to.
///
/// True when the server supplied it. Worth surfacing: it is the difference between "we agree
/// because I checked" and "we agree as far as I know".
///
-public sealed record ResolvedContract(SyncContract Contract, bool FromServer);
+///
+/// The newest version the server has for this major, or null when it offered none — either because
+/// it holds no version of this major or because it would not say. Compare against
+/// 's version to see whether this client is current.
+///
+///
+/// Why the newer versions were passed over, each prefixed with the version it refers to. Empty when
+/// the client is on the newest, and the thing to log when it is not.
+///
+public sealed record ResolvedContract(
+ SyncContract Contract,
+ bool FromServer,
+ ContractVersion? HighestOffered,
+ IReadOnlyList Blockers)
+{
+ ///
+ /// True when a newer version exists that this client could not take. The condition worth a log
+ /// line: being behind is fine, being behind without anyone knowing why is not.
+ ///
+ public bool IsBehind => HighestOffered is { } highest && highest > Contract.Version;
+}