Skip to content

URN composition

Hadron uses URNs (Uniform Resource Names) to identify every addressable entity — orgs, users, agents, apps, memories, nodes, edges, and more. URNs are stable, globally unique, and human-readable. This page is the reference for how they're built.

The rules below are grammar v2 — the flat, pool-rooted form — locked by spec cor:urn:010 (URN grammar & safety). Every v1 form (the :: hierarchy, the @<handle> sigil, the app::agent::memory ownership chain) is accepted on input forever and normalized to the v2 canonical — see Legacy forms and the alias map. The platform emits v2 today.

Anatomy of a URN

Every Hadron URN has one flat shape, with a single colon throughout:

hrn:<type>:<root>[:<container-slug>]:<name-or-loc>
  • hrn:<type>: — the canonical scheme prefix (hrn:, "Hadron Resource Name", in the spirit of AWS's arn:) and the type word naming the kind. The legacy scheme urn:<type>: is accepted on input forever; the platform always emits hrn:.
  • <root> — exactly one atom: the root principal, an org name or a user handle (see The root principal and the pool).
  • <container-slug> — a per-type fixed position, present only for a resource that lives inside another: the memory slug for node / edge / asset / noderev, the app slug for apprun. There is no ownership chain — the old app::agent::memory nesting is gone.
  • <name-or-loc> — the resource's own slug or loc. Colons inside a node loc are just loc structure (a loc like review:sort-imports is two atoms of one loc).

Why a single colon works

With the ownership chain removed, every type has a fixed positional arity, so one separator parses deterministically. The :: delimiter existed only to mark variable-length hierarchy levels — and that variability is gone. The single colon is a consequence of flattening, not an independent choice. (A deep node URN dropped ~40% in length versus v1 — 109 → 65 characters — by removing the chain.)

The root principal and the pool

The <root> atom names the entity's owner/author principal — an organization (its name) or a user (its handle). Both draw from one uniqueness namespace per server instance, so a root atom resolves to exactly one principal by constraint. That's why no @ sigil and no org: / user: marker is needed to tell an org from a user — GitHub runs exactly this model (a user and an org cannot share a name).

Guardrails make the pool safe (enforced at create/rename — a registration policy, not a parse rule):

  • Cross-kind, case-insensitive uniqueness at every handle/org create and rename.
  • Tombstoned release — a released root name is retired, not recycled, so an identity stays stable over time.
  • Dotted-root domain verification — a dotted root (a domain like acme.com) requires proof of domain ownership; handles are dot-free by policy.

The shape of each entity

Entity v2 URN shape Example
Organization hrn:org:<name> hrn:org:baragaun.com
User hrn:user:<handle> — the only handle-addressed entity hrn:user:holger
Memory hrn:mem:<root>:<slug> hrn:mem:holger:my-stuff
Agent hrn:agent:<root>:<slug> — root is the author hrn:agent:acme.com:code-generator
App hrn:app:<root>:<slug> hrn:app:baragaun.com:internal-ops
Node hrn:node:<root>:<mem>:<loc…>computed, not stored hrn:node:baragaun.com:secureid:auth:tokens
Edge hrn:edge:<root>:<mem>:<loc…>computed; loc is an opaque terminal hrn:edge:baragaun.com:secureid:auth:tokens:imports:crypto
Asset hrn:asset:<root>:<mem>:assets:<asset-id> hrn:asset:baragaun.com:secureid:assets:cl…
Secret hrn:secret:<root>:<name>org/user owner only hrn:secret:acme.com:stripe-key
App run hrn:apprun:<root>:<app>:<run-id> hrn:apprun:baragaun.com:dev-helper:01hx…
Node revision hrn:noderev:<root>:<mem>:<loc…>:<rev>end-anchored hrn:noderev:baragaun.com:some-mem:tasks:run-bg:r3
Node data <node-or-apprun-urn>#data — a fragment, not a type hrn:node:baragaun.com:some-mem:some-loc#data

Notes on the shapes

  • Node and edge URNs are computed, never stored as their own field — a node URN is derived from root + memory-slug + loc, so moving a node re-addresses it by construction (its internal id stays stable). An edge's memory is its source node's memory (spec 037); its loc is stored, but the URN string is still computed, so a node and an edge can share a loc and stay distinct — told apart by the node vs edge type word.
  • Edge loc is opaque. Source and target are the edge's data (FK columns), never parsed from the URN string. A source:target display is cosmetic and is not spec'd as parseable.
  • Secret — the owner is an org or a user only; the value never appears in the URN.
  • App run — "current" / "latest" / "previous" are queries that resolve to a concrete run URN, never magic run-ids in URN space.
  • Node revision is end-anchored: the last atom is the revision id (r<N>, a per-node monotonic number), the first post-root atom is the memory, and everything between is the (variable-length) node loc.
  • Node data#data is a fragment of its parent node or app-run URN, not a standalone type.

Installed agents stay author-rooted

When an Agent is installed into another org's App, its URN is still the agent's own author-rooted flat formhrn:agent:<author>:<slug>. The v1 <installing-org>::<app>::<author-org>:<slug> install chain is retired. Two same-slug agents from different authors stay distinct because their roots (the authors) differ. Only a derived local copy (clone / import / install-materialization) gets a locally minted, collision-checked alias slug — see the local-alias mint rule.

Slug rules

Every slug atom follows the same rules:

  • Charset: letters, digits, dots, underscores, hyphens. Must start and end with a letter or digit.
  • Regex: /^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/
  • Length: 64 characters maximum.
  • Case-sensitive storage; case-insensitive reserved-word check.
  • @ is not in the slug charset — it is reserved for the federation fallback (below) and legal in no atom.

The charset allows dots so domain-name roots like a.b.c.com and dotted-namespace identifiers work uniformly.

Reserved words

A slug equal (case-insensitively) to any registered type word or role marker is rejected at entity-create time. Agent, AGENT, aGeNt all fail. The reserved-word check is the only case-insensitive part of slug validation.

Type registry

The set of reserved type words (and role markers) is a fixed registry.

Group Words
Core types org, user, mem, agent, app, node, edge, asset, secret, apprun, noderev, appkey, aiconf, tool, server, userapikey, agentschedule, agentwebhook, license, subscription, usage, reference, session, platform
Fragments #data (of a node or app-run) — data is a fragment word, not a type
Node types abstract, partial, parent, plan, prompt, record, task, review
Node roles chat, chat-message, config, conversation, event, goal, stage
Node parts condition
Memory-role markers system, app-mem, group-mem, app-user, priv

Type words are matched case-sensitively at parse. The v1 word memory remains accepted on input and is normalized to mem. The registry is closed but extensible — each addition is a spec change.

loc: is not a type; the pre-2026-05 loc: relative-path prefix is removed and any URN containing a loc: segment is rejected.

Federation — a bare root is federation-scoped, not universal

Because Hadron is open-source, anyone can run a server, so the pool's uniqueness is inherently per-instance — two servers can both mint holger. A bare root is therefore federation-scoped, never implied-universal:

  • hadronmemory.com runs an optional global name registry. Participating servers reserve globally-unique org/user names at root-create time, so a bare URN stays resolvable across the registry-sharing federation with no server segmenthrn:agent:hadronmemory.com:some-agent means the same agent on every federated server.
  • An island server enforces uniqueness locally and doesn't call the registry.
  • @ is reserved as the fallback cross-server delimiter — <urn>@<instance> — for island / unregistered roots. v2 URNs carry no server segment; resolving an @instance reference is later work.

Verified-domain org roots are globally absolute; handle roots are registry-or-instance-scoped. Global naming is not resource transfer — the registry makes a cross-server URN unambiguous but does not by itself let one server fetch and run another's resource.

Legacy forms and the alias map

Every v1 form — the urn: scheme prefix, the :: hierarchy, the @<handle> / user: owner sigil, the app: / memory: marker segments, and the full ownership chain — is accepted on input forever (the no-deprecation posture, issue #239) and normalized toward the flat canonical.

But migration assigns the new slugs, and a collision auto-suffixes one (author-derived, e.g. code-generator-baragaun.com), so an old chain URN cannot be string-rewritten to its flat form — the mapping is data. Resolution uses a stored alias map, not a textual transform. Without it, "accepted forever" would silently decay to "accepted if unambiguous."

Local-alias mint. Whenever a foreign-rooted resource is materialized under a local root — clone, import, install-materialization, or account merge — the local URN gets a locally minted, collision-checked alias slug, never the foreign slug adopted verbatim. Link / share / subscribe surfaces read the resource in place (owner-rooted, no local URN) and can't collide.

So two callers submitting two different alias forms for the same logical URN still resolve to the same entity, and the platform stores and emits one canonical v2 spelling.

Emission status

v2 is the canonical grammar and the platform emits it today. The unified principal pool deployed, the online migration backfilled the stored alias map, the server flipped emission v1 → v2, and new memories mint flat single-atom v2 slugs at creation. Legacy :: / @ / chain URNs keep resolving via the stored alias map. You'll still see v1 spellings in older data — stored URNs minted before the flip, and links written by hand or copied from earlier docs — and they remain valid input forever. No client still emits v1: see Where to use the canonical form for the per-surface breakdown.

A memory slug is one atom — including an App's shared memory

Every memory URN is <root>:<slug>, with the slug a single atom. The one place that used to be an exception was an App's shared memory — the writable space a team App holds for its Team Agent, where the group chat and worklog live — which was minted as a chain (<org>:<app>:<agent>:shared). It is now minted flat like everything else, as <root>:<app-slug>-shared (acme.com:platform-team-app-shared), with a distinguishing suffix if that slug is taken.

That matters beyond tidiness, because a multi-atom memory slug makes the nodes inside it unaddressable: a node URN is <root>:<memory>:<loc>, and with more than one atom in the memory position there is no way to tell where the memory ends and the loc begins. So while a shared memory carried the old chain form, a URN or portal link to the team chat inside it did not resolve — you had to reach those nodes by id, or by passing the memory as its own argument (-m, or the memoryUrn parameter on an MCP tool).

If a team memory of yours still shows the chain form, it predates the migration; its nodes become addressable once it is backfilled, and its old URN keeps resolving afterwards through the alias map above.

Case sensitivity

URN slug storage is case-sensitive. hrn:agent:org:Coding-Agent and hrn:agent:org:coding-agent are two different URNs. The reserved-word check is the only case-insensitive part of slug validation.

Where to use the canonical form

Hadron accepts alias forms on input but emits a single canonical v2 form in storage, audit logs, and API responses.

Surface Form
GraphQL responses Canonical v2
MCP tool inputs Any alias accepted; canonical v2 persisted/emitted
Audit logs Canonical v2
Database storage Canonical v2
Portal <Urn> rendering Canonical v2
hadron CLI output Canonical v2
User-pasted inputs Any alias accepted (v1 or v2)

Every surface emits v2 today, clients included: the portal composes v2 URNs for its chips and its live URN preview (hadron-portal #684), and the hadron CLI emits hrn:mem:<root>:<slug> and hrn:node:<root>:<memory>:<loc>.

Legacy forms stay valid input everywhere, so a v1 URN copied from an older screen, doc, or script continues to resolve — see Legacy forms and the alias map.

API input contract — URN inputs must be fully qualified

Per spec 022, every API surface that accepts a URN — MCP tool, GraphQL resolver, hadron-client SDK function — requires URN inputs to be fully qualified. (ID-shape inputs are accepted per the exception below.)

Entity type Minimum shape Examples
org <org> acme.com or hrn:org:acme.com
user <handle> holger or hrn:user:holger
memory <root>:<memory> acme.com:mmdata or hrn:mem:acme.com:mmdata
agent / app <root>:<slug> acme.com:coding-agent or hrn:agent:acme.com:coding-agent
node <root>:<memory>:<loc> acme.com:mmdata:review:sort-imports or hrn:node:acme.com:mmdata:review:sort-imports

The prefixed examples use the canonical hrn: scheme; the legacy urn: scheme and the v1 :: spellings (e.g. hrn:node:acme.com::mmdata::review:sort-imports) are equally accepted on input and normalized.

Relative-form URNs (review:sort-imports without the <root>:<memory> prefix) are rejected at the API boundary:

URN "review:sort-imports" is not fully qualified. Expected a node URN with a
root, a memory, and a loc (e.g., "acme.com:mmdata:review:sort-imports").

GraphQL surfaces this as a typed error: { extensions: { code: "URN_NOT_QUALIFIED" } }. MCP tool error results carry the same code.

Why fully-qualified at the API boundary?

A user prompt like "add a review node 'sort-imports'" lets the agent construct either a relative URN (review:sort-imports, depending on a hidden "active memory" the user can't see) or a fully qualified one (acme.com:mmdata:review:sort-imports, picking one memory unambiguously). With two writable memories, the relative form silently routes to whichever the server's fallback picks. Spec 022 closes that path: the agent layer picks the memory (asking the user when ambiguous), and the API rejects anything less than a fully-qualified URN.

Exception: ID-shape inputs

Inputs matching the ID regexes (^c[a-z0-9]{24}$ CUID or ^[0-9a-f]{32}$ 32-char hex) are treated as internal IDs and bypass the qualification rule — callers that already hold an entity's ID keep working unchanged. For the parameterized memory-role markers (app-user, priv, group-mem), an ID-shape parameter resolves as an internal ID and anything else as a slug; both address the same memory.

Exception: content text

The qualification rule applies at the API boundary, not inside content prose. A YAML import or a node's markdown body may legitimately contain relative URNs or other non-qualified strings — only inputs flowing through GraphQL inputs, MCP tool arguments, or SDK call signatures are subject to the rule.

See spec 007 (Entity Reference Convention) for the ID-or-URN rule and spec 022 (Fully-Qualified URN Inputs) for per-tool enforcement.

  • Memory ownership and lifecycle — which principal owns each memory and how the root maps to ownership.
  • Node types — the nodeType values that appear in a node's loc/role.
  • GraphQL API — the generated schema, where every URN-bearing field is documented.