ADR 0005 — Node identity omits the default port
Status: proposed (2026-08-17)
Context
A graph node is identified by its URL string and compared exactly: the store
looks up s.nodes[url] and matches backlinks with e.To != url, with no
normalization anywhere. Identity is therefore whatever spelling the writer
used, and two spellings of one document are two documents.
Today the intended canonical form is mark://host:port/path with the default
port filled in, because fetch.ParseMarkURL supplies protocol.DefaultPort
when the address omits one. That intent was never enforced. demarkus-mcp
rebuilt the canonical form explicitly before crawling; the CLI, TUI, and broker
passed through whatever the caller typed. PR #318 closed the crawl entry by
canonicalizing inside graphstore.CrawlAndPersist, using the caller's own
parseURL so each transport keeps its own rule.
The damage was real before that fix. A rebuild of the demarkus-soul graph
produced 388 nodes for 192 documents, 204 keyed mark://soul.demarkus.io/...
and 167 keyed mark://soul.demarkus.io:6309/.... A second symptom rode along:
EtagFetcher keys etags canonically while nodes were keyed raw, so Merge
never attached an etag from a portless crawl and every conditional re-crawl
silently degraded to a full refetch.
Three doors remain open after PR #318:
SeedFromExportingests rows from another producer's/graph.mdverbatim.- The read paths (
GetNode,Backlinks,BacklinksEnriched) trust the caller's key. The broker'shandleMarkBacklinksvalidates the URL shape and then queries with the agent's raw string, so a near-miss returns "no backlinks" rather than an error. links.Resolvereturns any destination containing://untouched, so an absolute body link written without a port creates a second node even under a canonical crawl.
Two further facts shape the decision. The system already separates identity
from dial address: mark_worlds returns a world's name and its dial address as
distinct columns, and the broker addresses worlds as mark://{worldName}/{path}
with no port at all, rejecting a port outright as an operator typo. And humans
and agents naturally write mark://host/doc.md, which today is the wrong
spelling.
Decision
Node identity is the authority with the default port omitted.
mark://host/pathwhen the server listens onprotocol.DefaultPort.mark://host:7000/pathwhen it does not. A non-default port stays part of identity because it names a different server.- The broker's
mark://{worldName}/{path}form is unchanged. It is already portless, so both transports now follow one rule rather than two.
Identity is not the dial address
fetch.ParseMarkURL keeps filling in the default port. Dialing needs a port;
identity does not. These are separate functions with separate jobs, and
conflating them is what produced the original bug. Canonicalization for
identity gets its own exported helper, and the transport keeps its own.
Normalize at every keyed door, not just the crawl
The store takes an injected canonicalizer, following the existing parseURL
pattern so the store never hardcodes a rule that is wrong for the broker.
Hardcoding "strip 6309" inside graphstore would rewrite mark://servicing/x
as mark://servicing:6309/x and corrupt every brokered world key.
It is applied on ingest (Merge, SeedFromExport, the crawl entry) and on
read (GetNode, Backlinks, BacklinksEnriched). Read-side normalization is
not redundant with ingest: a mismatch on read returns an empty result, which is
indistinguishable from a document that genuinely has no backlinks, and a silent
wrong answer is the failure mode this whole class of bug keeps producing.
Accept both forms forever
Ingest accepts either spelling and stores the stripped one. Published graphs
written under the old rule keep parsing, and their rows are stripped on the way
in. graph.json bumps schemaVersion from 1 to 2 and strips keys on load, so
existing stores migrate instead of being rejected by the version check.
There is no flag day and no coordinated release. Hub aggregates can be republished whenever it suits.
Consequences
- The spelling people naturally type becomes the correct one, which closes the absolute-body-link hole by making the common case canonical rather than wrong.
- Identity matches RFC 3986 section 6.2.3, which removes a scheme's default port during normalization, and matches the broker's existing split between a world's name and its dial address.
/graph.mdrow shape changes. Producers are thedemarkus-agentfederation crawler, which publishes hub aggregates, andmark_graph_publish. Consumers are the MCP seed path, the broker seed path, and the library floor, which has broken once before on an export format change. Accept-both keeps every one of them working across the transition.- Moving a world off the default port changes the identity of every node it serves. This is already true and is not a regression, but it means a port remains load-bearing whenever it is not the default.
- Tests that pin the old rule change with it: the fedcrawl producer-consumer
contract test, the MCP and broker seed tests, and
TestCrawlAndPersistCanonicalizesStartURL, whose expectation inverts.TestCrawlAndPersistKeysMatchAcrossURLFormssurvives untouched, because it asserts that the two spellings agree rather than which one wins. That is the test carrying the real invariant.
Alternatives rejected
- Keep the port always. Internally consistent, and it is what the current data already looks like. Rejected because it leaves the natural spelling permanently wrong, keeps two identity rules across the two transports, and does nothing about absolute body links.
- Normalize on read only. Cheaper, and it would answer queries correctly today. Rejected because stored data stays forked on disk, so every export and every seed keeps propagating both spellings.
- Hardcode the rule inside
graphstore. Rejected as wrong for the broker, as above. - Reject non-canonical input. Honest, and it surfaces the problem instead
of hiding it. Rejected because a store seeded from another world's
/graph.mdcannot control what that producer wrote, and refusing to seed is worse than normalizing.
Deferred
- Whether the LOOKUP catalog and the OKF export should adopt the same identity rule. They key by path within a world today and are unaffected.
- Case normalization of the authority.
mark://Host/xandmark://host/xremain distinct until there is evidence anyone writes the former.