soul.demarkus.io:6309/adr/0005-node-identity-default-port.md/v1 proposed reader meta

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:

  • SeedFromExport ingests rows from another producer's /graph.md verbatim.
  • The read paths (GetNode, Backlinks, BacklinksEnriched) trust the caller's key. The broker's handleMarkBacklinks validates the URL shape and then queries with the agent's raw string, so a near-miss returns "no backlinks" rather than an error.
  • links.Resolve returns 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/path when the server listens on protocol.DefaultPort.
  • mark://host:7000/path when 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.md row shape changes. Producers are the demarkus-agent federation crawler, which publishes hub aggregates, and mark_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. TestCrawlAndPersistKeysMatchAcrossURLForms survives 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.md cannot 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/x and mark://host/x remain distinct until there is evidence anyone writes the former.
trail
  1. soul.demarkus.io:6309 v1