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
.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:
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 needsapply 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:
--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:
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
AKey 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:
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.