Skip to content

DatabaseId.ToString()'s %2E escaping is normalised away by System.Uri, so it yields two spellings and no disambiguation #599

Description

@jeremydmiller

Summary

DatabaseId.ToString() escapes . to %2E so TryParse can find the Server/Name separator. But the only consumer of that string embeds it in a System.Uri, and Uri canonicalisation decodes %2E straight back to .. The escaping therefore delivers none of the disambiguation it was written for, while creating two publicly-reachable spellings of one identity.

The two spellings

public record DatabaseId(string Server, string Name)
{
    public string Identity => $"{Server}.{Name}";                          // literal dots

    public override string ToString()
        => $"{EscapeSegment(Server)}.{EscapeSegment(Name)}";               // %2E

    private static string EscapeSegment(string value) => value
        .Replace("%", "%25", StringComparison.Ordinal)
        .Replace("/", "~",   StringComparison.Ordinal)
        .Replace(".", "%2E", StringComparison.Ordinal);
}

Wolverine's EventSubscriptionAgentFamily.UriFor interpolates ToString() into an agent URI and calls new Uri(...).

Why the escaping doesn't survive

%2E encodes ., which is unreserved in RFC 3986, so System.Uri normalises it away. Verified on .NET 9:

new Uri("marten://main/database-test%2Ezorg%2Eaws.claims1/p/all/1").ToString()
  -> marten://main/database-test.zorg.aws.claims1/p/all/1        // %2E decoded

So EventSubscriptionAgentFamily's own DatabaseId.Parse(uri.Segments[2].Trim('/')) on a round-tripped URI sees literal dots and falls back on LastIndexOf('.') — exactly the ambiguity the escaping was meant to remove. It happens to give the right answer whenever the database name contains no dots, which is why nothing has failed loudly.

The cost downstream

Uri.ToString() decodes; System.Text.Json serialises a Uri through OriginalString, which does not. One agent identity therefore reaches a client spelled two ways depending on whether it travelled as a Uri or as a string, and joins between the two silently miss. That is CritterWatch#878 — agent health rendering as "unknown" because a Uri-keyed map and a ToString()-derived map don't join. I have fixed it on the CritterWatch side by canonicalising to the decoded form, but the root asymmetry is here.

Options

  1. Escape with something Uri won't normalise. %2E is the one choice that cannot survive; almost anything else would (~ already does, which is why the / escape works).
  2. Drop the escaping and change the separator so Server and Name are unambiguous without it.
  3. Make Identity and ToString() agree, whichever way, so there is only one spelling to reason about.

Happy to take a swing at whichever you prefer — flagging rather than patching because the choice affects the agent-URI grammar and its parser, and there may be persisted URIs to consider.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions