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>:— the canonical scheme prefix (hrn:, "Hadron Resource Name", in the spirit of AWS'sarn:) and the type word naming the kind. The legacy schemeurn:<type>:is accepted on input forever; the platform always emitshrn:.<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 fornode/edge/asset/noderev, the app slug forapprun. There is no ownership chain — the oldapp::agent::memorynesting is gone.<name-or-loc>— the resource's own slug or loc. Colons inside a node loc are just loc structure (a loc likereview:sort-importsis 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
locis stored, but the URN string is still computed, so a node and an edge can share a loc and stay distinct — told apart by thenodevsedgetype word. - Edge loc is opaque. Source and target are the edge's data (FK columns),
never parsed from the URN string. A
source:targetdisplay 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 —
#datais 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 form — hrn: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
segment —
hrn:agent:hadronmemory.com:some-agentmeans 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@instancereference 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.
Related¶
- Memory ownership and lifecycle — which principal owns each memory and how the root maps to ownership.
- Node types — the
nodeTypevalues that appear in a node's loc/role. - GraphQL API — the generated schema, where every URN-bearing field is documented.