From 2afc36e61ba366ed7ae02f114f62234a0153d6cb Mon Sep 17 00:00:00 2001 From: nxships <2096086+nxships@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:57:38 +0200 Subject: [PATCH] feat(sync): choose the highest offered version this client can honour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveAsync took the server's document unread. That is right until a version removes a field this client reads, at which point it has agreed to a document it cannot honour. Now it walks the offered versions newest-first and takes the first one ClientSupport passes. Falling behind is a normal outcome, not a failure: the previous version keeps working, which is the entire reason old minors stay registered. ResolvedContract carries HighestOffered and Blockers so the caller can say why, and IsBehind marks the case worth a log line. The reason is returned rather than logged here because the client owns the log, and a silent downgrade is the one outcome nobody can diagnose later. Which collections the peer reads and which it writes decides what can break it, so grantedScopes is a parameter. Null falls back to every scope the local document declares — safe but pessimistic, holding the client back over collections its key may not even touch. The real set comes from a handshake, which is why the caller now handshakes first and resolves after. Descending order means the search stops at the first version that fits. Describe returns the exact version asked for rather than negotiating, so a client already on the newest pays one request and a client that has to move pays two. Raises the NexusKit.Sync floor to 0.6.0, which is where ClientSupport lives. --- Directory.Packages.props | 2 +- .../ContractResolution.cs | 118 +++++++++++++++--- 2 files changed, 101 insertions(+), 19 deletions(-) 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; +}