Reference
Single-page view · as markdown
The JSON API
Universal Directory / Reference
Everything udd can do is exposed as JSON under /api. The debug console is a client of this API and nothing more.
Requests and responses are application/json. There is no authentication: anyone who can reach the port has full control of the directory.
Object payload #
Endpoints that return an object use one shape:
refPath appears only on values of reference-syntax attributes: the target's current native path, or null if the reference dangles.
Every merge atom — existence, placement, each attribute value — carries a stamp, the replication record that decides merges:
absent lists absence atoms: values (or, for a single-valued attribute, the whole attribute — key: null) that were removed and persist as stamped residue so the removal can win future merges. Rewriting an attribute with an unchanged value does not restamp it; multi-valued value sets are unordered and render in canonical (set-key) order.
Objects under a derived repair additionally carry conflicted / orphaned / cycleBroken flags, plus effectiveName (the CNF display name) and effectiveParent where they differ from the stored facts; dn and path always render the effective view. Child summaries carry name (effective), ghost, and the three repair flags.
Endpoints #
| Method & path | Purpose |
|---|---|
GET /api/node | This node's replication identity and sync state: {nodeId, invocationId, usn, hasRoot, purgeHorizon, morgueSize, rollbackLatched, erasureDebt, storage, boot, machine, utd, watermarks} — storage carries the durability marks (generation, durable vs appended bytes and USNs, dead flag), boot the boot report, machine this machine's identity ({object, machineKey} — the machine object's GUID and public key). |
GET /api/journal | The node's observability journal (newest last, capped, survives restarts): {at, kind, detail} — merges that discarded concurrent input, refused records and pulls, derived repairs, rollback events. |
GET /api/storage | The storage view: generation files with sizes, durability marks, rotation threshold, erasure debt, background-rebuild status and last outcome. |
GET /api/fabric | The fabric view: this machine's identity and GIP bind, every discovered peer with its link state (idle/dialing/connected/viaInbound/backoff), live QUIC stats (RTT, congestion window, lost packets, MTU, wire bytes), inbound connections (each carrying machine {guid, name} once identified via gip/ident, null while unverified), channels (live channels: connection, peer, service, direction, age, bytes) and recentChannels (a ring of finished channels and failed opens with duration, bytes, and outcome), the supervisor's config, and a ring of timestamped fabric events. |
POST /api/fabric/redial/{guid} | Debug: clear a peer's backoff and dial it now. |
GET /api/msgs | The machine-talk log, oldest first (RAM only — capped at 200 records, gone on restart): each record is {atMs, direction (in/out), peerGuid, peerName, text, outcome}, where sent records carry outcome delivered (+rttMs), failed (+detail), or unconfirmed (+detail), and received records carry received. {enabled: false} on a node without a fabric. |
POST /api/msgs/send | Send one ud/msgs message and block for its outcome (bounded: 5 s channel-open + 10 s ack). Body: {"to", "text"} — to is a machine GUID or a caseless machine name resolved against the fabric's targets; text is 1–16384 bytes. HTTP errors are for request problems only (422 unknown-recipient, 422 bad-text, 503 no-fabric); a send that ran returns 200 carrying the finished record, including failed and unconfirmed outcomes — those are data, not transport errors. |
POST /api/storage/rotate | Debug: seal the active WAL, open the next generation, kick a background rebuild. |
POST /api/storage/rebuild | Debug: kick a background snapshot rebuild (no rotation). |
GET /api/root | The root object. |
GET /api/morgue | Every tombstone: {id, name, class, stage, effectiveStage, at, lastPath}. |
POST /api/gc | Run local GC now (harden aged tombstones, purge expired ones); returns the new horizon. |
POST /api/objects/{id}/restore | Restore a soft-deleted object (top-down; refuses conflicts and elapsed windows). |
POST /api/objects/{id}/shred | Security-erase (subtree-wide from alive, or escalate an existing soft delete). |
GET /api/objects/{id} | One object by GUID. |
GET /api/objects/{id}/children | Child summaries, in caseless name order: {id, name, class, system, hasChildren}. |
POST /api/objects/{id}/children | Create a child. Body: {"name", "class", "attributes"?} — attributes maps attribute name → value list and is applied atomically with the create (definitions are born whole). Returns 201 + the object. |
PATCH /api/objects/{id} | Rename and/or move (ModifyDN shape). Body: {"name"?, "parent"?}, parent by GUID. |
PUT /api/objects/{id}/attributes/{name} | Replace an attribute's values wholesale. Body: {"values": [{"value", "meta"?}]}. An empty values deletes the attribute. Returns the updated object. |
DELETE /api/objects/{id} | Soft-delete the object and its whole subtree (restorable tombstones). Returns 204. |
GET /api/path and GET /api/path/{path} | Resolve a native untyped path (caseless) to its object: /api/path/Sales/Jack. Bare /api/path is the root. |
GET /api/schema | The compiled schema summary (below). |
Attribute names in URLs and bodies are caseless and accept wire names (Acme-badgeColor); the stored spelling is always the schema's canonical one.
Every mutating endpoint acknowledges only after the write is durable — a 200/201/204 means the disk holds it. Attribute values are capped at 1 MiB each (large blobs are a future side-store's job, not an atom's).
The schema summary #
GET /api/schema returns the compiled schema, sorted by name:
quarantined lists definitions excluded from the effective schema — replication can deliver a facet before its attributes. Compilation is total: it never fails, it quarantines what cannot join yet (with the reason) and lets it join automatically when its dependencies arrive. References to tombstoned definitions compile out instead (the facet keeps working minus its dead member). Originating schema writes are still strictly gated: a local write that would grow the quarantine is refused with schema-violation.
Errors #
Failures return {"error": "<code>", "message": "<human text>"} with:
| Code | Status | Meaning |
|---|---|---|
bad-object-id | 400 | Path segment is not a GUID. |
no-such-object | 404 | Object (or path/reference target) does not exist. |
no-root | 503 | A joined store that has not yet received its first sync has no root. |
name-in-use | 409 | Sibling name taken (caseless, type-blind). |
invalid-name | 422 | Name fails validity rules (or definition-name grammar). |
unknown-class | 422 | No class by that name. |
class-not-instantiable | 422 | Class is marked non-instantiable (domainRoot, configuration). |
unknown-attribute | 422 | No attribute definition by that name. |
attribute-not-permitted | 422 | Attribute is outside the object's surface. |
single-valued | 422 | Multiple values for a single-valued attribute (or meta). |
syntax-violation | 422 | Value fails its syntax (bad integer/boolean/timestamp, non-GUID or dangling reference). |
illegal-meta-key | 422 | Meta key unknown or not in the attribute's legalMeta. |
duplicate-value | 409 | Value set contains duplicates under the matching rule. |
unknown-facet | 422 | attachedFacets value is not a facet definition. |
facet-has-values | 409 | Facet detach / extension delete refused while exclusively-provided attributes hold values. |
already-deleted | 409 | Mutation aimed at a tombstone (restore first, or leave the dead be). |
ghost-object | 409 | The object's class isn't in this node's schema yet; originating writes refuse until sync converges. |
rollback-quarantine | 409 | The node detected it is running rolled-back (restored) state: its invocation was re-minted and originating writes are suspended until a completed pull heals it. |
not-restorable | 409 | Restore refused: wrong stage, elapsed window, or parent not alive. |
root-is-protected | 409 | The root cannot be deleted, renamed, or moved. |
system-object-protected | 409 | System objects refuse moves and deletion (their attributes are writable). |
invalid-schema-placement | 422 | Wrong class/parent combination in or around the schema subtree. |
schema-definition-immutable | 409 | Definitions are add/delete only. |
definition-in-use | 409 | Unused-delete rule refused the deletion; the message enumerates the blockers by name. |
schema-violation | 422 | Trial recompile failed; the write was rolled back (message carries the compiler's reason). |
would-create-cycle | 409 | Move would place an object under itself or a descendant. |
The base schema
Universal Directory / Reference
The base schema is the set of system definitions every directory is born with: 26 attributes, 11 facets, and 11 classes — 48 objects in all, flat under /Configuration/Schema and immutable forever.
All new definitions live in namespaces; the unprefixed name space tabulated below is reserved. The base definitions are minted at domain creation by the first node, as its ordinary originating writes. There are no well-known GUIDs — a joining node receives everything by sync.
Attributes #
| Attribute | Syntax | Values | Matching | Aliases | legalMeta |
|---|---|---|---|---|---|
description | string | multi | caseless | ||
displayName | string | single | caseless | ||
givenName | string | single | caseless | ||
surname | string | single | caseless | sn | |
email | string | multi | caseless | mail | |
sid | string | single | exact | objectSid | |
member | reference | multi | exact | expiry | |
expiry | timestamp | single | exact | ||
syntax | string | single | caseless | ||
singleValued | boolean | single | exact | ||
matching | string | single | caseless | ||
legalMeta | reference | multi | exact | ||
aliases | string | multi | caseless | ||
lifecycle | string | single | caseless | ||
system | boolean | single | exact | ||
attributes | reference | multi | exact | required | |
required | boolean | single | exact | ||
facets | reference | multi | exact | ||
attachedFacets | reference | multi | exact | ||
dnPrefix | string | single | caseless | ||
targetClass | reference | single | exact | ||
instantiable | boolean | single | exact | ||
maxTimeApart | integer | single | exact | ||
timeUntilShred | integer | single | exact | ||
networkAddress | endpoint | multi | caseless | ||
machineKey | publicKey | multi | exact |
Notes: description is multi-valued (following the LDAP RFCs where they and Active Directory disagree). attachedFacets is engine-level — legal on every object regardless of surface, and the vehicle for instance facet attachment. Aliases are recorded on definitions but not yet used for lookup. expiry and required exist to serve as meta keys (on member and attributes respectively). instantiable (unset = true) marks classes users may instantiate. maxTimeApart and timeUntilShred are the domain's lifecycle knobs, in seconds, held on the /Configuration object itself: timeUntilShred is the restore window (default 30 days — past it a soft-deleted record hardens to a skeleton), maxTimeApart the purge horizon and future partition contract (default 180 days — past it tombstones purge entirely). networkAddress holds a machine's network endpoints — multi-valued because a DNS name, an IPv4, and an IPv6 address are legitimately concurrent addresses of one machine.
The endpoint syntax #
networkAddress uses the endpoint value syntax: host[:port], where the host is a DNS name, an IPv4 literal, or a bracketed IPv6 literal ([2001:db8::1]:6000), and an omitted port designates the well-known GIP port, 5390 (GIP — the General Interlink Protocol — is UD's intra-domain machine-to-machine protocol; the syntax ships today, the protocol does not yet). The grammar is deliberately strict, because a loose syntax can never be tightened once values persist:
- no schemes (
quic://…) and no whitespace anywhere; - DNS names are ASCII (IDN arrives as punycode), dot-separated labels of letters, digits, and interior hyphens, ≤ 63 bytes per label and ≤ 253 total, with no trailing dot;
- anything whose last label is all digits must be a valid IPv4 literal —
999.1.1.1is refused rather than silently treated as a DNS name — and leading zeros in octets (historical octal) are refused; - IPv6 must be bracketed, zone ids (
%eth0) are refused; - an explicit port is 1–65535 with no leading zeros.
Values compare caselessly as opaque strings: DC1.corp and dc1.CORP are the same set member, but dc1.corp and dc1.corp:5390 are two distinct values even though they designate the same place — set matching is literal; interpreting endpoints is the dialer's job.
The publicKey syntax #
machineKey uses the publicKey value syntax: <algorithm>:<hex>, where the only algorithm today is x25519 and the payload is exactly 64 lowercase hex characters (32 bytes) — one canonical spelling per key, compared exactly. This is the machine's GIP identity key: udd new mints the keypair at enrollment, keeps the private half in the data directory's machine-key file, and records the public half here, replicated like any other fact. The attribute is multi-valued so a future key rotation can hold old and new keys through an overlap window. The algorithm prefix is deliberate agility: a future signing key gets a new prefix, not a new syntax.
Facets #
| Facet | Attributes (* = required) |
|---|---|
described | description, displayName |
person | givenName, surname, email |
securityPrincipal | sid * |
membership | member |
schemaDefinition | aliases, lifecycle *, system * |
attributeSchema | syntax *, singleValued *, matching *, legalMeta |
facetSchema | attributes |
classSchema | facets, dnPrefix, instantiable |
extensionSchema | targetClass *, facets * |
replicationConfig | maxTimeApart, timeUntilShred |
machine | networkAddress, machineKey |
Required-ness is enforced today only where compilation demands it (schema definitions must be born complete); enforcement for ordinary objects is pending.
machine is the surface for anything reachable on the network; both attributes are deliberately optional on it — a pre-staged machine that has not yet enrolled has neither an address nor a key. The computer class composes it, and udd new creates one computer object for the machine it runs on. (The class is not called machine because definitions are siblings under /Configuration/Schema, where names are unique regardless of kind — the facet holds that name.)
Classes #
| Class | dnPrefix | Instantiable | Facets |
|---|---|---|---|
domainRoot | — (never in a DN) | no | described |
organizationalUnit | OU | yes | described |
user | CN | yes | described, person, securityPrincipal |
group | CN | yes | described, securityPrincipal, membership |
attributeDefinition | CN | yes | described, schemaDefinition, attributeSchema |
facetDefinition | CN | yes | described, schemaDefinition, facetSchema |
classDefinition | CN | yes | described, schemaDefinition, classSchema |
schemaNamespace | OU | yes | described |
classExtension | CN | yes | described, schemaDefinition, extensionSchema |
configuration | CN | no | described, replicationConfig |
computer | CN | yes | described, machine |
The self-hosting anchor: the classDefinition object under /Configuration/Schema is an instance of itself; every other definition's kind resolves from there.
An instantiable class must carry a dnPrefix (compile-enforced); non-instantiable classes are engine-created only. /Configuration is the singleton configuration instance — its class composition is what makes the replication knobs writable there, and future config areas graft on via classExtension. Besides /Configuration/Schema, the base tree also contains /LostAndFound, a system OU born with the domain: the destination replication repairs derive orphans and broken cycles into (the domain root is a replication head from birth). It also contains /Domain Controllers, the default home for machine objects — a convention, not structure: nothing in UD locates domain controllers by path, and a machine object is free to live anywhere.
Naming rules
Universal Directory / Reference
Object names #
An object's bare name must be:
- non-empty and at most 255 bytes,
- free of control characters,
- free of
/(the native path separator), - without leading or trailing whitespace.
Everything else — spaces, Unicode, =, , — is legal; DN composition escapes what needs escaping. These rules are deliberately strict: loosening a validity rule later is safe, tightening one is a data migration.
Caseless comparison (pinned) #
Sibling uniqueness, attribute-name lookup, path resolution, and caseless-matched value sets all compare under Unicode canonical caseless matching (Unicode §3.13): two strings are equal iff
NFD(casefold(NFD(a))) == NFD(casefold(NFD(b)))
So Jack = jack, STRASSE = Straße (ß case-folds to ss), and composed é = decomposed e + ́. Stored spellings are always preserved; the folding governs comparison only. This function is pinned: changing it once data persists would shift uniqueness invariants underneath stored data.
Distinguished names #
DNs compose leaf-first from typed components: CN=Jack,OU=Sales. The component type is the object's class's dnPrefix; the value is the bare name escaped per RFC 4514 — a backslash before any of " + , ; < > \ anywhere, before a leading space or #, and before a trailing space. The root object contributes nothing; its DN is the empty string.
Native paths #
The untyped address form: / followed by bare names joined with /, root-first — /Sales/Jack, resolving case-insensitively. The root's path is /. Because / is banned in names, paths are unambiguous.
Definition names and wire names #
Schema definition names (attributes, facets, classes, extensions) follow a stricter grammar than object names: a letter followed by letters or digits — camelCase by convention, and no hyphens, because the hyphen is the namespace separator. Namespace names additionally allow dots between segments (each segment a letter followed by letters or digits): Acme and reverse-DNS com.acme.tools are both legal, and reverse-DNS is the convention for anything redistributable — it makes accidental collisions between vendors nearly impossible. A namespaced definition's wire name is Namespace-name (Acme-badgeColor, com.acme.tools-badgeColor) — the single hyphen is unambiguous because neither side may contain one. Base definitions are unprefixed, and that unprefixed space is reserved forever.
The namespace names gip and ud (caselessly) are permanently reserved: they are GIP's service namespaces (gip/relay, ud/drs), and service names share the schema's namespace universe.
dnPrefix values follow RFC 4512 keystring grammar (a letter, then letters, digits, or hyphens).
CNF display names #
When replication merges two live siblings whose names fold equal, the earliest placement claim keeps the name and the later one displays as <stored name> CNF:<guid> (laddering (2), (3)… past pathological collisions). This is a derived view, not a rename: the stored name is untouched, the CNF form resolves in paths, and an ordinary rename dissolves it.
The reserved descriptor x #
The name x (caselessly) is permanently reserved and can never be defined as an attribute, facet, class, extension, or namespace name. UD reserves it as its untyped-lookup descriptor so that no schema element can ever collide with that use.