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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 10:12:20 +02:00
commit cbd3b4f3ce
6 changed files with 33 additions and 13 deletions

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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")),
}
}

View file

@ -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;
}

View file

@ -546,6 +546,18 @@ namespace GameCult.Networking
: null
};
/// <summary>
/// Builds the wire error for a selection that selects a document variant, which CultNet does not carry yet: code
/// <c>variant_unsupported</c>, details <c>{ field: "subscriptionId", value }</c> when a subscription was ended by it.
/// </summary>
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 }
};
/// <summary>
/// Builds the wire error for an out-of-target reference refusal (R-N): code
/// <c>reference_outside_target</c>, no details.

View file

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