From cbd3b4f3ce3963979a7b30e88305c467e5c8a638 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 10:12:20 +0200 Subject: [PATCH] CultNet: a withdrawn hop subscription is refused with a typed error and its rows are withdrawn Reconcile's withdrawal sent an untyped error with no subscription id and left the peer holding the rows the subscription had delivered. It now sends removals for those rows, then a variant_unsupported error whose details carry field "subscriptionId" and the id (the existing details slots; no new wire key). The subscribe and per-change paths use the same code. The error schema and the Rust CultNetErrorCode accept variant_unsupported, so a Rust peer decodes it instead of refusing an unknown code. Co-Authored-By: Claude Sonnet 5 --- contracts/cultnet/cultnet.error.schema.json | 6 +++--- .../contracts/cultnet/cultnet.error.schema.json | 6 +++--- packages/cultnet-rs/src/contracts.rs | 5 ++++- .../CultNetDatabaseSubscriptionServer.cs | 13 +++++++------ src/GameCult.Networking/CultNetSchemaMessages.cs | 12 ++++++++++++ .../CultNetDocumentRegistryVariantTests.cs | 4 ++++ 6 files changed, 33 insertions(+), 13 deletions(-) diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 8653cbd5..15880fa1 100644 --- a/contracts/cultnet/cultnet.error.schema.json +++ b/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json index 8653cbd5..15880fa1 100644 --- a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "code": { "description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.", "type": ["string", "null"], - "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"] + "enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"] }, "details": { "description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "properties": { - "field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] }, - "value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] }, + "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] }, "asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 }, "current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 } } diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index cc50171d..f31ade85 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -250,7 +250,7 @@ impl CultNetShardDescriptor { } } -/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings +/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the five strings /// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth /// string it does not know refuses to decode rather than guess a meaning for it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -260,6 +260,7 @@ pub enum CultNetErrorCode { CursorStale, CursorInvalid, ReferenceOutsideTarget, + VariantUnsupported, } impl CultNetErrorCode { @@ -269,6 +270,7 @@ impl CultNetErrorCode { Self::CursorStale => "cursor_stale", Self::CursorInvalid => "cursor_invalid", Self::ReferenceOutsideTarget => "reference_outside_target", + Self::VariantUnsupported => "variant_unsupported", } } @@ -278,6 +280,7 @@ impl CultNetErrorCode { "cursor_stale" => Ok(Self::CursorStale), "cursor_invalid" => Ok(Self::CursorInvalid), "reference_outside_target" => Ok(Self::ReferenceOutsideTarget), + "variant_unsupported" => Ok(Self::VariantUnsupported), other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")), } } diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 60bb66a5..f3150740 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -208,7 +208,7 @@ namespace GameCult.Networking { // Q6: a snapshot that selects a variant is refused to this subscriber as a typed error. Withdraw(key, sendRemovals: false, forgetRequest: true); - peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, subscriptionId)); return Task.CompletedTask; } catch @@ -330,7 +330,7 @@ namespace GameCult.Networking catch (NotSupportedException refusal) { // Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key. - peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message)); return; } @@ -358,11 +358,12 @@ namespace GameCult.Networking } catch (NotSupportedException refusal) { - // Q6: the selection selects a variant, which CultNet cannot carry. Refused as the subscribe path refuses it: the - // subscription is withdrawn, so the peer is told once, not once per store change; a change never throws out of + // Q6: the selection selects a variant, which CultNet cannot carry. The subscription is withdrawn, so the peer is + // told once, not once per store change; the rows it delivered are withdrawn too (removals first), so the peer + // does not keep them as live, and the typed error names the subscription that ended. A change never throws out of // the change handler. - Withdraw(key, sendRemovals: false, forgetRequest: true); - key.Peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message }); + Withdraw(key, sendRemovals: true, forgetRequest: true); + key.Peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, key.Id)); return; } diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index f343fad7..4daa76d5 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -546,6 +546,18 @@ namespace GameCult.Networking : null }; + /// + /// Builds the wire error for a selection that selects a document variant, which CultNet does not carry yet: code + /// variant_unsupported, details { field: "subscriptionId", value } when a subscription was ended by it. + /// + public static CultNetErrorMessage ForVariantUnsupported(string message, string? subscriptionId = null) => + new CultNetErrorMessage + { + Error = message, + Code = "variant_unsupported", + Details = subscriptionId == null ? null : new CultNetErrorDetails { Field = "subscriptionId", Value = subscriptionId } + }; + /// /// Builds the wire error for an out-of-target reference refusal (R-N): code /// reference_outside_target, no details. diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs index a4658117..64de1093 100644 --- a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs @@ -272,6 +272,10 @@ namespace GameCult.Networking.Tests await database.PutAsync(new CultRecordKey("citer-four"), Citer("citer-four", VariantKey)); await WaitUntilAsync(() => errors.Any(error => error.Error.Contains(VariantKey.Value)), Diagnostic); + var refusal = errors.First(error => error.Error.Contains(VariantKey.Value)); + Assert.That(refusal.Code, Is.EqualTo("variant_unsupported")); + Assert.That(refusal.Details?.Value, Is.EqualTo("cited"), "the peer can tell which subscription ended"); + await WaitUntilAsync(() => changes.Any(change => change.SubscriptionId == "cited" && change.ChangeKind == "removed" && change.RecordKey == BaseKey.Value), Diagnostic); Assert.That(changes.Where(change => change.SubscriptionId == "cited").Select(change => change.Document?.RecordKey), Does.Not.Contain(VariantKey.Value), "a variant never reaches a subscriber as a record");