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");