Concepts

Records and signing

Every PNS record is a signed JSON envelope. This page defines the envelope, the canonical JSON every signature is made over, the keys and key ids, who signs which record, and the data of every record type.

On this page
  1. The envelope
  2. What is signed
  3. Canonical JSON
  4. Ed25519 keys
  5. Key ids
  6. Who signs which record
  7. Where each record may appear
  8. Record data, type by type
  9. TTL
  10. Signing a record yourself

The envelope

Every PNS record is a JSON object with the same eight fields (PNS section 7). Any other top-level field is refused. This is the signed ENDPOINT record of the example name oasis.character:

An ENDPOINT recordjson
{
  "name": "oasis.character",
  "type": "ENDPOINT",
  "version": 1,
  "ttl": 300,
  "data": {
    "url": "https://endpoint.example.com/session",
    "modes": [
      "text",
      "voice"
    ],
    "directions": [
      "bring"
    ]
  },
  "issued": "2026-10-23T12:10:00.000Z",
  "kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
  "signature": "86tNfhoNBTvMH4Ob4R-zNoM0jANQgpr1aP6HKYVgDcANvXiMFsTQ_zE6067huraKPU2cv7x4qE2c_PyzSkrEDA"
}
FieldTypeRule
namestringThe full portal name the record belongs to. It must be the name it is published for.
typestringOne of the 11 record types: ID, ENDPOINT, ALIAS, STATUS, PERSONA, GUARDIAN, CONSENT, SOURCE, WORLD, CERT, TXT.
versionintegerFrom 1. Each new record for a slot must carry a higher version than any the slot has held, even one since removed, so an old record can never be replayed.
ttlinteger, secondsFrom 30 to 86,400. See TTL below.
dataobjectThe record's content, defined per type below. At most 16,384 bytes of canonical JSON; large content goes in a package that a record links to.
issuedstringAn ISO 8601 time in UTC: YYYY-MM-DDTHH:MM:SS, an optional fraction, then Z. The registry writes milliseconds, from new Date().toISOString().
kidstringThe id of the signing key: the root key’s id for ID, STATUS, CERT or GUARDIAN; the name’s current key id, <pid>#k<n>, for everything else.
signaturestringThe Ed25519 signature, base64url without padding (64 bytes, 86 characters).

A slot is one record type for one name; TXT records have one slot per kind, and CONSENT records one per experience, as CONSENT:<experience>. Publishing a record replaces the one in its slot. An owner can also remove a record they made; the removal is published like any other change, and an active name can never remove a record it needs. A removed slot keeps its highest version as a floor, which the Registry API reports as versionFloors.

What is signed

The signature covers the canonical form of every field except signature. To sign a record:

  1. Take the envelope without signature: name, type, version, ttl, data, issued, kid.
  2. Write it as canonical JSON (below) and encode the text as UTF-8.
  3. Sign those bytes with Ed25519, using the key named by kid.
  4. Encode the 64-byte signature as base64url without padding and add it as signature.

These are the exact bytes the example ENDPOINT record signs:

Canonical JSON of the record without its signaturetext
{"data":{"directions":["bring"],"modes":["text","voice"],"url":"https://endpoint.example.com/session"},"issued":"2026-10-23T12:10:00.000Z","kid":"pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1","name":"oasis.character","ttl":300,"type":"ENDPOINT","version":1}

To verify, do the same and check the signature against the public key: the Jubilee root key for ID, STATUS, CERT or GUARDIAN, and the name’s key from its ID record for every other type. Certificates and bundles use the same method; see Certificates and Resolution.

Canonical JSON

Every signature on the Mesh is made over canonical JSON, so two parties that agree on a value always agree on the bytes. The registry’s implementation is exactly this:

  • Objects: keys sorted by UTF-16 code units (JavaScript’s default sort), written as {"key":value,...} with no whitespace. A key whose value is undefined is left out. Only plain objects are allowed: a Date, a Map or a class instance is refused.
  • Arrays: in their own order, no whitespace. An undefined element is refused.
  • Strings and numbers: exactly as JSON.stringify writes them. NaN and the infinities are refused.
  • Literals: true, false, null.
  • Anything else (a BigInt, a function, a symbol) is refused.
  • The text is then encoded as UTF-8.

For every value the registry produces this output is identical to RFC 8785, the JSON Canonicalization Scheme: ECMAScript number serialization, ECMAScript string escaping, and keys sorted by UTF-16 code units. Anything JSON cannot represent without ambiguity is refused rather than coerced, because a signer that quietly turned undefined into null and a verifier that dropped it would disagree about the bytes.

A valuejs
{
  version: 1,
  type: "ENDPOINT",
  name: "oasis.character",
  data: { url: "https://endpoint.example.com/session", modes: ["text", "voice"], protocol: undefined },
  ttl: 300,
  note: "Café ☕",
  ratio: 1.50,
  big: 1e21,
  Zebra: true,
  éclair: null,
}
Its canonical JSON (216 bytes of UTF-8)text
{"Zebra":true,"big":1e+21,"data":{"modes":["text","voice"],"url":"https://endpoint.example.com/session"},"name":"oasis.character","note":"Café ☕","ratio":1.5,"ttl":300,"type":"ENDPOINT","version":1,"éclair":null}

Note the sorted keys (capital letters before lowercase, é last), protocol: undefined left out, 1.50 written as 1.5, and 1e21 as 1e+21. Keys sort as strings, so "10" comes before "9":

text
{ b: 1, a: 2, B: 3, "é": 4, Z: 5, "10": 6, "9": 7 }
{"10":6,"9":7,"B":3,"Z":5,"a":2,"b":1,"é":4}

What the registry refuses, with its own error messages:

CallError
canonicalize({ n: NaN })non-finite number at $.n
canonicalize({ list: [1, undefined] })undefined at $.list[1]
canonicalize({ when: new Date(0) })non-plain object at $.when
canonicalize({ count: 10n })unsupported bigint at $.count

The same rules as standalone JavaScript, for verifiers that do not use the registry’s library. This code ran during the build of this page and produced the same bytes as the registry:

mesh-core.jsjs
// Canonical JSON: keys sorted by UTF-16 code units, no whitespace, strings and numbers exactly
// as JSON.stringify writes them (RFC 8785 compatible for every value the registry produces).
// Anything JSON cannot represent without ambiguity is refused, never coerced.
function canonicalize(value) {
  if (value === null) return "null";
  switch (typeof value) {
    case "boolean":
      return value ? "true" : "false";
    case "number":
      if (!Number.isFinite(value)) throw new Error("non-finite number");
      return JSON.stringify(value);
    case "string":
      return JSON.stringify(value);
    case "object": {
      if (Array.isArray(value)) {
        return "[" + value.map((item) => {
          if (item === undefined) throw new Error("undefined inside an array");
          return canonicalize(item);
        }).join(",") + "]";
      }
      const proto = Object.getPrototypeOf(value);
      if (proto !== Object.prototype && proto !== null) throw new Error("not a plain object");
      const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
      return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(value[k])).join(",") + "}";
    }
    default:
      throw new Error("unsupported " + typeof value);
  }
}

// base64url without padding. The decoder also accepts standard base64, padded or not.
function b64urlEncode(bytes) {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64urlDecode(text) {
  const b64 = text.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
  const binary = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4));
  return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}

Ed25519 keys

Every signature on the Mesh is Ed25519 (RFC 8032), made and checked through the Web Crypto API, the same code in Workers, Node and browsers. portal.js falls back to a JavaScript implementation in browsers without native Ed25519.

  • A public key is a JWK: { "kty": "OKP", "crv": "Ed25519", "x": "<32 bytes, base64url>" }.
  • A signature is 64 bytes, written in base64url without padding. Readers also accept standard base64, padded or not.
  • A malformed key or signature is a failed verification, never an error that might be mistaken for success.

There are two kinds of key:

  • The Jubilee root key signs certificates, the ID, STATUS, CERT, GUARDIAN records, and every resolution bundle. Its public half is served at /root-key on every PNS server, and portal.js pins it at build time.
  • A name’s own key is generated on the registrant’s device at birth. Only the public key is sent to the registry, which writes it into the ID record and the birth certificate. The private key never reaches Jubilee, so the registry can only accept owner records that already verify against the key on the certificate.

The examples on these pages use the RFC 8032 test keys: test 2 as an example root key (jubilee-root-example) and test 1 as the name’s key. They are public and must never be used for anything real.

Key ids

KeykidExample
The Jubilee root keyA configured namejubilee-root-2026-10
A name's key, version n<pid>#k<n>pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1
  • A name’s first key, at birth, is #k1. Each key rotation adds one, and so do a transfer and a recovery, because the new owner, or the owner who lost the key, brings a new key.
  • A rotation is made by the signed-in owner, or an admin on an organization’s team, with two-factor authentication, and it must be signed by the current key (pm.key-rotation/1). An owner who has lost the key recovers the name instead, with the same verification it was registered with (Recovery).
  • The ID record carries the current key id as keyId. An owner record must carry exactly that kid; a record signed with an older key no longer verifies.
  • When an active name rotates its key, the rotation must include re-signed copies of every owner record, so the Mesh never serves a record that no longer verifies. A name that is born but not yet active may rotate alone; its old records are dropped.

Who signs which record

RecordSigned byMade
IDThe registry, with the Jubilee root keyAt birth, and again on every key change: a rotation, an accepted transfer, or a completed recovery.
ENDPOINTThe owner, with the name's own keyUploaded by the owner.
ALIASThe owner, with the name's own keyUploaded by the owner.
STATUSThe registry, with the Jubilee root keyAt birth (born), on activation, sleeping and waking, suspension, restoration, a missed renewal (dormant), death, rest and release, retirement and termination, and when an active name is transferred or recovered.
PERSONAThe owner, with the name's own keyUploaded by the owner.
GUARDIANThe registry, with the Jubilee root keyFor a .character, at birth and on every change of the people behind it: a designation, a pause, a succession. Display names only, never contact details.
CONSENTThe owner, with the name's own keyUploaded by the owner: an avatar's own permission, or a Guardian's approval of one role in one experience.
SOURCEThe owner, with the name's own keyUploaded by the owner.
WORLDThe owner, with the name's own keyUploaded by the owner.
CERTThe registry, with the Jubilee root keyAt birth, on every re-certification, and when a death certificate is issued.
TXTThe owner, with the name's own keyUploaded by the owner, one record per kind.

The registry makes ID, STATUS, CERT, GUARDIAN itself and refuses them as uploads. The owner uploads ENDPOINT, ALIAS, PERSONA, CONSENT, SOURCE, WORLD, TXT through PUT /v1/names/:pid/records, and each one is accepted only if its envelope is valid for the name, its kid is the name’s current key id, its signature verifies against that key, and its version is newer than the stored one.

Where each record may appear

A record type outside its namespaces is refused on upload and flagged by every verifier.

RecordValid in
IDEvery namespace
ENDPOINTEvery namespace
ALIASEvery namespace
STATUSEvery namespace
PERSONA.character, .avatar, .figure
GUARDIAN.character
CONSENT.character, .avatar, .figure
SOURCE.figure, .realm, .destination, .history, .event, .game, .movie, .music, .book
WORLD.realm, .destination
CERTEvery namespace
TXTEvery namespace

Record data, type by type

Each record type defines the shape of data. The registry checks every rule below before it accepts a record, and every example on this page passes those checks.

ID (registry)

FieldTypeRule
pidstringThe permanent ID. Must equal the bundle's pid.
rootTypestringThe namespace without its dot. Must equal the name's namespace.
familystringidentity, destination or experience. A record or certificate signed before October 25, 2026 may say environment or place, the two families that became Destinations.
publicKeyJWKThe name's current Ed25519 public key.
keyIdstringThe current key id, <pid>#k<n>. Owner records must carry it as their kid.
bornstringWhen the name was born, ISO 8601 UTC.
IDjson
{
  "pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
  "rootType": "character",
  "family": "identity",
  "publicKey": {
    "kty": "OKP",
    "crv": "Ed25519",
    "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
  },
  "keyId": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
  "born": "2026-10-23T12:00:00.000Z"
}

STATUS (registry)

FieldTypeRule
statestringOne of born, active, sleeping, suspended, dormant, dead, resting, released, retired or terminated.
sincestringWhen this state began, ISO 8601 UTC.
reasonstring, optionalawaiting-activation on a born name that is not yet active; the reason given, on a suspended name.
STATUSjson
{
  "state": "active",
  "since": "2026-10-23T12:30:00.000Z"
}
Registry statePublished STATUSMeans
pending, paid, expiredNever publishedNot born yet, or an unpaid hold that ran out.
bornborn, reason awaiting-activationBorn, with its certificate, but not activated yet.
activeactiveThe only state in which a portal opens.
sleepingsleepingA character whose owner's model key failed or ran out of credit. It shows a sleeping screen instead of an error, and wakes when the key works again.
suspendedsuspended, with the reasonStopped by Jubilee, for example while a report is reviewed. Can be restored.
dormantdormantA missed renewal. Held for a year; paying the renewal revives it at once.
deaddead, a tombstoneA year dormant: a death certificate is issued, and the PID and DNA fingerprint are retired forever.
restingresting, a tombstoneThe address rests a year, so no one can pose as the former name.
releasedreleased, a tombstoneThe address is open to anyone again, as a new name with a new PID.
retiredretired, a tombstoneRetired by its owner, with a death certificate. Final.
terminatedterminated, a tombstoneTerminated by Jubilee, with a death certificate. Final.

A tombstone (dead, resting, released, retired, terminated) resolves with its ID, STATUS and CERT only, so it stays discoverable and nothing its owner signed can be activated again. See The life of a name.

CERT (registry)

FieldTypeRule
certificateIdstringThe current birth certificate, crt_ and a ULID.
kindstringAlways birth: the CERT record points at the current birth certificate.
versionintegerThat certificate's version, from 1.
fingerprintstringsha256:<64 hex> of the whole signed certificate. See Certificates.
urlstringhttps address of the certificate page.
personaFingerprintstring, optionalsha256:<64 hex>, for persona namespaces: the fingerprint bound into the certificate.
dnaFingerprintstring, optionalsha256:<64 hex>, for a .character: its Character DNA fingerprint, signed into the certificate. See Characters.
issuedAtstringWhen that certificate was issued.
deathobject, optionalOnce the name has died, or been retired or terminated: { certificateId, fingerprint, issuedAt } of its death certificate.
CERTjson
{
  "certificateId": "crt_01JBX3Q8ZA6M2T9K4V7N1R3W5Y",
  "kind": "birth",
  "version": 1,
  "fingerprint": "sha256:8985a02b0098e873418e5de978268581dfc2c1777279d3799e9345379b33e9ae",
  "url": "https://theportalmesh.com/certificate/pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
  "issuedAt": "2026-10-23T12:00:00.000Z",
  "personaFingerprint": "sha256:f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24",
  "dnaFingerprint": "sha256:eacb55f7bb7e1c0240780e660ade3ddea5a4fc08d36fb23f3f52f983a604e54d"
}

GUARDIAN (registry)

Every .character publishes a GUARDIAN record: who answers for it right now. The registry writes it at birth and issues a new version whenever the people behind the character change. It names people by display name only; contact details are never published, and a record that carries one is refused.

FieldTypeRule
classstringfamily (registered by a person) or worker (registered by an organization).
parentobject or nullA Family character’s Parent: { role, shownAs, displayName }. The role follows the Character DNA: parent for a humanoid, owner for a pet, sponsor for an alien, guardian for any other creature. null for a Worker.
guardianobject or nullA Worker character’s organization, its Guardian: { displayName }. null for a Family character.
responsibleGuardianobject{ displayName }: the one living, verified person answerable for the character now.
statusstringactive; paused when the character has no living Guardian (it does not run until someone steps in); or succeeded when a successor has taken over.
successorDesignatedbooleanWhether the registrant has filed a guardianship designation naming a successor.
GUARDIAN for oasis.character, a Worker characterjson
{
  "class": "worker",
  "parent": null,
  "guardian": {
    "displayName": "Example Studio"
  },
  "responsibleGuardian": {
    "displayName": "Jordan Lee"
  },
  "status": "active",
  "successorDesignated": true
}
GUARDIAN for a Family characterjson
{
  "class": "family",
  "parent": {
    "role": "parent",
    "shownAs": "mother",
    "displayName": "Ruth Brown"
  },
  "guardian": null,
  "responsibleGuardian": {
    "displayName": "Ruth Brown"
  },
  "status": "active",
  "successorDesignated": false
}

Both passed the registry’s validateRecordData while this page was built. The same record with an email address as the Responsible Guardian’s name was refused: “GUARDIAN never carries contact details.” The Character Server checks GUARDIAN before every activation, and a paused character does not run.

ENDPOINT

FieldTypeRule
urlstringWhere sessions open: an https:// or wss:// address, at most 2,048 characters. portal.js opens sessions with an HTTPS POST, so an endpoint for web pages uses https://.
modesarrayOne or more of text, voice, vr, hologram.
directionsarrayOne or more of go, bring: the directions the portal allows.
protocolstring, optionalFree text, for example pm.session/1. Not checked.
permittedActionsarray.agent only, and required there: a non-empty list of the actions the agent may take, each up to 200 characters.
ENDPOINT for oasis.characterjson
{
  "url": "https://endpoint.example.com/session",
  "modes": [
    "text",
    "voice"
  ],
  "directions": [
    "bring"
  ]
}
ENDPOINT for an agent, with its permitted actionsjson
{
  "url": "https://agent.example.com/session",
  "modes": [
    "text"
  ],
  "directions": [
    "bring"
  ],
  "protocol": "pm.session/1",
  "permittedActions": [
    "read invoices",
    "send payment reminders"
  ]
}

ALIAS

FieldTypeRule
targetstringA valid portal name, not the name itself. It must be registered and born when the record is uploaded, and must not be retired or terminated when the name is activated.
timestring, optionalA time frame in the registry’s grammar, such as 1944 or 30AD. See time frames.

ALIAS may not cross root types (section 9 of the standard). An experience (.history, .event, .game, .movie, .music, .book) must point to the place it happens in, a .destination or a .realm, or to a name in its own namespace: experiences point, they do not duplicate. A name in any other namespace may point only to a name in its own namespace.

ALIAS from an experience to its destination and timejson
{
  "target": "normandy.destination",
  "time": "1944"
}

PERSONA

FieldTypeRule
personaVersionstringThe persona's own version, up to 64 characters.
hashstringsha256:<64 hex>: the persona fingerprint. It must equal the fingerprint on the current birth certificate.
profilestringAn https address where the persona's public profile can be read.
PERSONAjson
{
  "personaVersion": "2026.10.23",
  "hash": "sha256:f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24",
  "profile": "https://files.theportalmesh.com/personas/f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24.json"
}

See Personas for what the hash commits to and how a new persona version is certified.

There are two kinds of CONSENT, told apart by whether the data names an experience.

An avatar’s own. The represented person’s permission, resting on their identity verification. Required for every .avatar; it keeps the plain slot CONSENT.

FieldTypeRule
verificationIdstringThe id of the owner's approved identity verification, up to 64 characters. The registry checks that it exists, belongs to the owner, and passed.
statementstringThe represented person's statement of permission.
grantedAtstringWhen permission was given, ISO 8601 UTC.
scopearray, optionalWhat the permission covers. Free text, not checked.
CONSENT for an avatarjson
{
  "verificationId": "ver_01JBX3Q8ZC5N7R2T4V6W8Y0A1B",
  "statement": "I permit this avatar to represent me.",
  "grantedAt": "2026-10-23T12:05:00.000Z",
  "scope": [
    "voice",
    "likeness"
  ]
}
FieldTypeRule
experiencestringThe experience’s name, written normalized and without a scheme, in one of .history, .event, .game, .movie, .music, .book.
rolestringThe character's role in it, up to 200 characters.
scriptHashstringsha256:<64 hex> of the published script the Guardian approved. A new script needs a new approval.
grantedAtstringWhen the Guardian approved, ISO 8601 UTC.
An experience CONSENT for oasis.character, slot CONSENT:redseacrossing.historyjson
{
  "name": "oasis.character",
  "type": "CONSENT",
  "version": 1,
  "ttl": 300,
  "data": {
    "experience": "redseacrossing.history",
    "role": "A guide who welcomes visitors on the shore",
    "scriptHash": "sha256:26df9a5b7eaded97d893acd8ddf88ec9d0e026ec16ca75b3809f4468f05fa345",
    "grantedAt": "2026-10-23T12:20:00.000Z"
  },
  "issued": "2026-10-23T12:20:00.000Z",
  "kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
  "signature": "EY-mZMhIqht2XlU_5gjLr9r5Fon6eW5zZqJGu6979I61INgZ7lPZmv_Np0YqL89INnCrRY-PtDn6wmtY9LpQBA"
}

It verified against the name’s key while this page was built. These were refused, with the registry’s own words:

SentRefused with
A character's CONSENT with no experienceA character's CONSENT is an approval for one experience: it needs experience, role and scriptHash.
An experience that is a placeCONSENT experience must be an experience:// name, such as redseacrossing.history.
No script hashCONSENT scriptHash must be the sha256 of the published script.

Withdrawing consent is removing the record, or replacing it for another script, through PUT /v1/names/:pid/records with "remove": [{ "type": "CONSENT", "kind": "redseacrossing.history" }]. The registry tells the Character Server at once, and the character leaves the experience immediately.

SOURCE

FieldTypeRule
groundingstringhistory, scripture, imaginative.
citationsarrayEach { title, url?, ref? } with a title up to 500 characters. At least one, unless the grounding is imaginative.
documentedarray, optionalFor a .figure, required: what is documented.
reconstructionstring, optionalFor a .figure, required: what is reconstruction.
notestring, optionalFree text.
SOURCE for a figure: documented statements kept apart from reconstructionjson
{
  "grounding": "history",
  "citations": [
    {
      "title": "Farewell Address",
      "ref": "Published September 19, 1796"
    }
  ],
  "documented": [
    "Served as the first President of the United States, from 1789 to 1797."
  ],
  "reconstruction": "Voice, manner and conversational phrasing are reconstructed from his letters and contemporary accounts."
}
SOURCE for a realm grounded in Scripturejson
{
  "grounding": "scripture",
  "citations": [
    {
      "title": "Revelation 21 and 22",
      "ref": "Revelation 21:1 to 22:5"
    }
  ],
  "note": "A depiction, not a claim to show the actual place."
}

For realms and destinations, publishing a SOURCE record opens a review by Jubilee, and the name cannot be activated until it is approved.

WORLD

FieldTypeRule
scenesarrayAt least one scene, { id, title }.
modesarrayOne or more of text, voice, vr, hologram.
capabilitiesobject, optionalThe capability profile. Free form.
anchorobject, optionalAn optional physical anchor, { lat, lon } in degrees. Approximate by design.
timeFramesarray, optionalThe frames offered: each { id, label, year?, now?, source? }. An id (up to 64 characters, unique) and a label are required; a frame is either now: true or has a whole-number year (BC negative); every frame that is not now must carry its own source: history, scripture or imaginative.
defaultTimeFramestringRequired with timeFrames: the id of the frame that opens when no time is asked for.
linksarray, optionalTime portals: { from, to } pairs of frame ids a visitor may jump between.
WORLD for jerusalem.destinationjson
{
  "scenes": [
    {
      "id": "old-city",
      "title": "The Old City"
    },
    {
      "id": "mount-of-olives",
      "title": "The Mount of Olives"
    }
  ],
  "modes": [
    "text",
    "voice",
    "vr"
  ],
  "anchor": {
    "lat": 31.78,
    "lon": 35.23
  },
  "timeFrames": [
    {
      "id": "30ad",
      "label": "Jerusalem in 30 AD",
      "year": 30,
      "source": "history"
    },
    {
      "id": "today",
      "label": "Jerusalem today",
      "now": true
    },
    {
      "id": "2100ad",
      "label": "Jerusalem in 2100, a depicted future",
      "year": 2100,
      "source": "imaginative"
    }
  ],
  "defaultTimeFrame": "today",
  "links": [
    {
      "from": "30ad",
      "to": "today"
    },
    {
      "from": "today",
      "to": "2100ad"
    }
  ]
}

TXT

Free-form verification and metadata. data may carry any fields; an optional kind (a short word, up to 64 characters) names what it is, and each kind has its own slot. The kinds below have rules the registry checks; other kinds are accepted as free form.

kindRequired byFields
rightsMovies, music and booksbasis: owns or licenses; statement.
ratingEvery experience namespacerating, a label of up to 32 characters such as PG-13, and minAge (0 to 21), which is what a door reads. In an experience namespace it also needs intensity, the default intensity (gentle, standard or full), and exit: true, the always-available exit.
packageMovies, music and booksurl (https) and an optional sha256 (sha256:<64 hex>).
castHistory, events and gamesmembers: a non-empty list.
scriptHistory, events and gamestext (up to 16,000 characters) or a url (https).
TXT rights statement (movies, music, books)json
{
  "kind": "rights",
  "basis": "licenses",
  "statement": "Example Pictures licenses this film for distribution on the Mesh."
}
TXT content rating (movies, music, books)json
{
  "kind": "rating",
  "rating": "PG",
  "minAge": 8,
  "intensity": "standard",
  "exit": true
}
TXT experience package (movies, music, books)json
{
  "kind": "package",
  "url": "https://files.theportalmesh.com/packages/example-movie.zip",
  "sha256": "sha256:07531963baeda377d96af8e056cb8d13b9f6e20eba0349410c0026df1b9c6053"
}
TXT content rating (history, events, games)json
{
  "kind": "rating",
  "rating": "PG-13",
  "minAge": 13,
  "intensity": "standard",
  "exit": true
}
TXT cast list (history, events, games)json
{
  "kind": "cast",
  "members": [
    {
      "role": "Narrator"
    },
    {
      "name": "eisenhower.figure",
      "role": "Supreme Allied Commander"
    }
  ]
}
TXT guided script (history, events, games)json
{
  "kind": "script",
  "url": "https://example.com/dday/script.json"
}

TTL

A record’s ttl is how many seconds a cache may keep it. Every record must have a TTL from 30 to 86,400 seconds (one day). The registry signs its own records with these defaults, and they are the recommended values for owner records:

RecordDefault TTL, seconds
ID3,600
ENDPOINT300
ALIAS300
STATUS60
PERSONA300
GUARDIAN300
CONSENT300
SOURCE3,600
WORLD300
CERT3,600
TXT300

A PNS server sets Cache-Control: public, max-age= to the lowest TTL in the bundle it serves, so a bundle with a STATUS record is cached for at most a minute. Clients still check status live before opening a portal.

Signing a record yourself

The registry website signs records in your browser: it opens your key file with your passphrase, signs, and uploads. The key is held in memory only. To do the same in your own tooling, open the key file (the pm.name-key/1 format), then sign with the standalone code below, which uses canonicalize and b64urlEncode from mesh-core.js. During the build of this page it produced the same signature as the registry for the example ENDPOINT record.

mesh-sign.jsjs
// The DER prefix of an Ed25519 PKCS#8 private key (RFC 8410); the 32-byte seed follows it.
const PKCS8_PREFIX = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20];

function signingKeyFromSeed(seed) {
  if (seed.length !== 32) throw new Error("an Ed25519 seed is 32 bytes");
  const pkcs8 = new Uint8Array(48);
  pkcs8.set(PKCS8_PREFIX, 0);
  pkcs8.set(seed, 16);
  return crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]);
}

// unsigned = { name, type, version, ttl, data, issued, kid }; kid is "<pid>#k<n>", the name's
// CURRENT key id. Returns the envelope with its base64url signature.
async function signRecord(unsigned, seed) {
  const key = await signingKeyFromSeed(seed);
  const bytes = new TextEncoder().encode(canonicalize(unsigned));
  const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, bytes));
  return { ...unsigned, signature: b64urlEncode(signature) };
}
Using itjs
// seed: the 32-byte Ed25519 seed from your key file (openKeyFile on the Registry API page).
const record = await signRecord(
  {
    name: "oasis.character",
    type: "ENDPOINT",
    version: 1, // the first ENDPOINT; later ones go above the stored version and any floor
    ttl: 300,
    data: { url: "https://endpoint.example.com/session", modes: ["text", "voice"], directions: ["bring"] },
    issued: new Date().toISOString(),
    kid: "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
  },
  seed,
);

The upload is the body of PUT /v1/names/:pid/records. The registry made ID, STATUS, CERT and GUARDIAN at birth; the owner sends the rest:

Request body: the first upload for oasis.characterjson
{
  "records": [
    {
      "name": "oasis.character",
      "type": "PERSONA",
      "version": 1,
      "ttl": 300,
      "data": {
        "personaVersion": "2026.10.23",
        "hash": "sha256:f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24",
        "profile": "https://files.theportalmesh.com/personas/f666f7b6e5c7b0a0c94366a7b55b902d2d3c1fb6d6d130922c70f7a274f00a24.json"
      },
      "issued": "2026-10-23T12:10:00.000Z",
      "kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
      "signature": "lk49Qy02Qyh-8UsNRJOrtcgwGleY0FSEvPLAtBmKxKcP0XQiKBYMLQkwJgCVYMSnUgs3qTW4_8Bt9pqb7-cEDQ"
    },
    {
      "name": "oasis.character",
      "type": "ENDPOINT",
      "version": 1,
      "ttl": 300,
      "data": {
        "url": "https://endpoint.example.com/session",
        "modes": [
          "text",
          "voice"
        ],
        "directions": [
          "bring"
        ]
      },
      "issued": "2026-10-23T12:10:00.000Z",
      "kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
      "signature": "86tNfhoNBTvMH4Ob4R-zNoM0jANQgpr1aP6HKYVgDcANvXiMFsTQ_zE6067huraKPU2cv7x4qE2c_PyzSkrEDA"
    }
  ]
}