fix: make DatabaseId's escaping survive a System.Uri round trip (#599) - #603
Merged
Conversation
DatabaseId.ToString() escaped '.' as "%2E" so TryParse could find the Server/Name
separator. But the only consumer of that string interpolates it into a System.Uri and
parses it back out of uri.Segments, and '.' is unreserved in RFC 3986 -- so Uri
canonicalisation decodes "%2E" straight back to '.'. The escaping delivered none of the
disambiguation it was written for, and the separator search fell back on LastIndexOf('.'),
which happens to be right only while the database *name* holds no dots.
Worse, it left two publicly-reachable spellings of one identity: Uri.ToString() decodes,
System.Text.Json serialises a Uri through OriginalString which does not, and Identity used
literal dots while ToString() used "%2E". A single agent identity reached a client spelled
two ways depending on how it travelled, and joins between the two silently missed. That is
CritterWatch#878.
Escape '.' as '!' instead. '!' is a sub-delimiter, which Uri preserves verbatim -- verified
on .NET 10, along with '~', "%25" and the rest of the sub-delims. Identity now returns the
same escaped spelling as ToString(), so there is exactly one form to reason about.
Also closes a pre-existing hole: '~' was the '/' escape but a literal '~' was never
escaped, so new DatabaseId("a~b", "c") came back out as "a/b".
Persisted agent URIs keep parsing: "%2E" is still decoded on the way in, and a bare '~' is
still read as '/'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #599.
The defect
DatabaseId.ToString()escaped.as%2EsoTryParsecould find theServer/Nameseparator. But the only consumer of that string interpolates it into aSystem.Uri(Wolverine'sEventSubscriptionAgentFamily.UriFor) and parses it back out ofuri.Segments— and.is unreserved in RFC 3986, soUricanonicalisation decodes%2Estraight back to..Confirmed on .NET 10:
new Uri(...)?%2E.%25~!$,;'*()+=So the escaping delivered none of the disambiguation it was written for, and the separator search fell back on
LastIndexOf('.')— right only while the database name holds no dots, which is why nothing failed loudly.The two spellings
Uri.ToString()decodes;System.Text.Jsonserialises aUrithroughOriginalString, which does not. AndIdentityused literal dots whileToString()used%2E. One agent identity therefore reached a client spelled two ways depending on how it travelled, and joins between the two silently missed — CritterWatch#878, where agent health rendered as "unknown".The fix
Escape
.as!— a sub-delimiter, whichUripreserves verbatim.Full escape alphabet, in application order —
!and~are the escape characters, so a literal one is escaped before anything else can produce one:%→%25%25already survived!→!!~→!~/→~.→!%2EIdentitynow returns the same escaped spelling asToString(), so there is exactly one form of one identity.Also closes a pre-existing hole:
~was the/escape but a literal~was never escaped, sonew DatabaseId("a~b", "c")came back out asa/b.Backwards compatibility
Persisted agent URIs keep parsing.
%2Eis still decoded on the way in (before the%25pass — a value holding the literal text%2Eis written%252E, which contains no%2Eof its own, so it survives that pass and decodes correctly in the next), and a bare~is still read as/.The one thing that changes for an old value is a segment containing a literal
!: written before this change it was left alone, and it now reads back as..Tests
survives_a_system_uri_round_tripis the check that was missing — it builds an agent-shaped URI, assertsToString()/AbsoluteUri/OriginalStringall agree, and parses the id back out ofuri.Segments. Verified it fails on the old implementation (along withidentity_and_to_string_are_the_same_spelling,round_trips_a_literal_bangandround_trips_a_literal_tilde— 7 failures total) and passes on the new one. FullCoreTestssuite green.Downstream
The CritterWatch side was already worked around by canonicalising to the decoded form; with one spelling now coming out of JasperFx that workaround can be retired. Worth a look at whether Wolverine's
EventSubscriptionAgentFamilywants theSegmentsindex revisited at the same time.🤖 Generated with Claude Code