Skip to main content
Everything under the control API can also be declared as YAML and applied from a git repository with relay apply (POST /api/apply). Apply and the catalog seed share one loader: for the catalog kinds, a seed and an apply of the same manifest tree produce the same rows and the same plan. The boot seed and a fetched catalog release load catalog kinds only; host keys, relay keys, teams, projects, service accounts, groups, roles, bindings and users reach the database through relay apply, relay seed --apply or the control API.

Apply

A file or a directory of .yaml documents is parsed locally, sent to the server, diffed against the stored rows and, unless --dry-run is given, written. Every entry in the plan gets one of seven actions: --force overwrites a dirty row with the manifest’s version and clears its dirty flag, so apply manages the row again from then on. It also lets the apply write a group a SCIM client manages; without it, a bundle that changes such a group is refused with 409 before anything is written. A dirty row the manifest matches is unchanged and nothing is written, so it stays dirty: exporting a hand-edited row and applying the export back reports no drift, and a later change to that document reports skip-dirty until --force is given.

Account, tenancy and RBAC documents

Team, Project, ServiceAccount, Group, Role and RoleBinding documents may declare apiVersion: auth.wyolet.dev/v1alpha1 instead of relay.wyolet.dev/v1alpha2. Both write the same rows, and one file may mix them with the other kinds. Under auth.wyolet.dev/v1alpha1 they are parsed strictly: an unknown field, a rule kind or verb outside the role vocabulary, metadata.owner (every owner of these kinds is derived), or a plain-text password fails the apply with the document’s index and the field’s path. Under relay.wyolet.dev/v1alpha2 unknown fields and metadata.owner are ignored. User documents are accepted under auth.wyolet.dev/v1alpha1 only; metadata.name is the username:
A password is a bcrypt hash (password.hash); plain text is refused. relay seed --apply also accepts password.valueFrom.env, the name of an environment variable of the seeding process whose value is hashed; POST /api/apply refuses it, so the API never reads the server’s environment. email, roles and disabled are declarative: one left out is cleared. password and identity (the identity provider’s issuer and subject) left out keep the stored value; null, "" or {} clears it. Changing an account’s roles requires an admin, and so does setting, changing or clearing its password or identity, or creating an account that carries one; the bundle is refused before anything is written. A changed password or identity ends the account’s sessions and revokes its tokens. A bundle that would leave no enabled admin is refused. relay seed --apply writes accounts without ending their sessions, though a changed password or identity still revokes their tokens.

Running it from CI

Run the dry run first so a pipeline can gate on drift before writing anything:
$RELAY_URL (default http://localhost:8081) or --url names the server; $RELAY_ADMIN_TOKEN or --token authenticates. A conflict entry means the row was not applied, so the run exits 1 even under --force: --force overrides an operator’s edit, not a write that landed mid-apply. Re-run to converge on the row as it now stands.

Prune

--prune deletes rows that are absent from the bundle, but only rows that carry every label in --selector (equality only). An unfiltered prune would delete every row the manifest happens to omit, so the selector is required. Pruning draws the same membership line as export (below): teams, groups, roles and role bindings are always prunable, except built-in roles; every other kind is prunable only when it is owned by a user, team or project. System-, provider- and host-owned rows, the catalog’s own data, are never candidates, whatever labels they carry: a manifest that omits a catalog provider or host is not asking for its removal. Accounts carry no labels, so prune never deletes a user. A prune never removes rows silently. Pruning a team, project or service account is refused while rows under it stay out of the prune; for a service account those are its keys and the role and policy bindings that name it as a subject. Select them too, or move them first. A document the same apply would write into a pruned team, project or service account is refused as well.

Authorization and partial failure

Every row in the plan is authorized before anything is written: each write needs apply on system at the row’s scope plus the row’s own verb, and a dry run is checked the same way. A denied write fails the whole apply with 403 (401 if the caller is not authenticated), and nothing is written. A change the governance settings or the license refuse also fails the whole apply with 403 before anything is written. A row the caller may not see is different: it is not a refused write but a document about a row outside their scope. Those entries come back as forbidden, named as the bundle named them, and the rest of the bundle still applies. relay apply exits 1 on them, so a pipeline applying a bundle wider than its own scope learns which documents were ignored. Writes then run one kind at a time, each kind in its own transaction. There is no transaction across kinds: if a write fails part-way, the apply returns 500 with the list of entries that already landed. Re-applying the same bundle converges: the rows that landed plan as unchanged, and the run continues from where it stopped.

Export

GET /api/export renders the tenant-owned rows as a manifest bundle in the shape apply accepts, so a deployment’s state can round-trip without hand-written YAML. Kinds are emitted in dependency order, each sorted by name for a stable diff:
Teams, groups, roles and role bindings are exported by kind, whoever owns the row: a scope or a grant is not its author’s property, so ownership can’t decide membership for these four (built-in roles are left out by name). Every other kind is exported by owner: only rows owned by a user, a team or a project. Catalog rows (system-, provider- and host-owned providers, hosts, models and pricing) are never exported; they come from the catalog, not from a git repository. Overlays on those catalog rows are exported, since the overlay itself is user-owned. Accounts are not exported. --include-system (?includeSystem=true) widens the owner rule for a backup: it adds every system-owned row of the exported kinds (what the admin bearer creates through the API or apply, and the relay’s own system rate limits), and every host- or provider-owned catalog row an operator edited through the API (a dirty row). Catalog rows nobody edited stay out, and so does every added row in a scoped export, since none of them has a team or project. Applied back to the deployment it came from, the bundle’s dirty rows match what is stored and plan as unchanged; on a newly seeded deployment they apply as updates. An edit made through apply does not mark a row dirty, so a catalog row changed that way is not included. A role binding at global scope ({kind: system}) has no team or project to scope it into, so it appears only in an unscoped export. Secrets never leave: a HostKey’s value is always omitted, whatever its valueFrom.kind. A Key exports keyHash, prefix and expiresAt, never previousKeyHash or graceUntil; a Key document also carries revokedAt, enabled, and the passthrough and payload-logging flags, none of which are secret. --scope team:<id> or --scope project:<id> restricts the export to one subtree. Overlays are skipped in a scoped export: they are keyed by their target catalog row, not by tenant. --kinds takes a comma-separated list of API plurals. --format is yaml (default) or json.

The $schema directive

Every document export renders is preceded by an editor directive:
The base is RELAY_PUBLIC_URL when the deployment sets one; otherwise the path is relative (/api/schemas/...). GET /api/schemas/{version} lists the kinds available at a schema version, and GET /api/schemas/{version}/{kind} serves one. Both are public: an editor resolving the directive has no session, and a schema describes shape, never data.

Keys in manifests

A Key document carries only keyHash. Generate the key locally with relay keygen, which prints the plaintext, the hash and the display prefix, and commit only the hash:
Only an admin can create a key from a document, since whoever chooses the hash knows the plaintext; other callers create keys with POST /api/keys. Once a key exists, its keyHash, prefix and principal are fixed: a document may omit them or repeat the stored values, and an apply that declares different ones is refused. Rotate a key with POST /api/keys/by-id/{id}/rotate. See the CLI reference for every command and flag, and Inference tokens for minting short-lived credentials instead of keys.