From a8506ce4c7be1af66308e0177ce4d41dfc44c2e5 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Mon, 13 Jul 2026 08:58:27 +0530 Subject: [PATCH 01/13] docs: RFC 0001 for declarative management of platform resources --- docs/rfcs/0001-declarative-reconcile.md | 317 ++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 docs/rfcs/0001-declarative-reconcile.md diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md new file mode 100644 index 000000000..0c23c2199 --- /dev/null +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -0,0 +1,317 @@ +# RFC 0001: Declarative management of platform resources + +| | | +|---|---| +| **Status** | Proposed (the PlatformUser kind shipped in v0.108.0; every other kind is proposed here) | +| **Author** | Rohil Surana | +| **Created** | 2026-07-09 | +| **Pull requests** | #1719 (shipped), #1731 and #1737 (drafts for this proposal) | + +## Summary + +Frontier gains a declarative way to manage its platform configuration. A YAML file +describes what should exist for one kind of resource. The `frontier reconcile` command +compares the file with the live server, prints a plan, and applies the difference through +the admin API. The `frontier export` command prints the live state in the same file format, +so a file can be seeded from a running server. + +Platform users shipped as the first kind and set the pattern. This RFC writes down the +rules every kind must follow, and proposes custom permissions and platform-level roles as +the next two kinds under those rules. Their implementation follows this RFC's approval and +changes with it. + +## Terminology + +| Term | Meaning | +|---|---| +| **SpiceDB** | The database Frontier uses to answer permission checks. It stores a schema (the rules) and relationships (the data). All Frontier pods share one SpiceDB. | +| **Base schema** | The SpiceDB rules built into the Frontier binary. It defines the object types (`app/organization`, `app/role`, ...) and how a permission check can be satisfied. It only changes at runtime to add custom permissions. | +| **Operator** | A person who runs a Frontier installation: edits desired-state files, reviews plans, and applies them. Different from a product user, who uses Frontier through applications. | +| **Kind** | One type of managed resource, for example `PlatformUser` or `Role`. Each kind has its own file format and its own rules. | +| **Reconcile** | Compare a desired-state file with the live server and make the server match. Runs through the admin API. | +| **Platform user** | A user or service account with platform-wide access: relation `admin` (superuser) or `member`. | +| **Predefined role** | One of the roles built into Frontier (`app_organization_owner`, `app_project_viewer`, ...). Boot creates them when missing. Other Frontier code looks them up by name, so their names must never change. | +| **Custom role** | A platform-level role an operator created. Fully owned by the desired-state file. | +| **Bootstrap service account** | The automation superuser the server creates at boot from `app.admin.bootstrap`. Meant for tooling like this. The server guards it, and the reconciler refuses to touch it. | + +## Problem + +Frontier's platform configuration was managed in three ways. All three had problems. + +1. **Superusers came from server config** (`app.admin.users`). Adding someone meant a config + change and a restart. Removing someone did not work at all: the server only promoted, + never demoted. Stale superusers stayed until someone cleaned the database by hand. +2. **Custom permissions and roles came from boot-time YAML** (`resources_config_path`). The + loader only adds and updates. It never removes anything. There is no preview: the first + sign of a bad file is a server that will not boot. Every change needs a restart. +3. **Everything else was changed by hand through the admin API.** Nothing records what + should exist. Changes drift silently. Nobody can review them. + +## Goals + +- One reviewable file that says what should exist, per resource kind. +- A plan (dry run) before every change. +- Removal works. All three old flows could add things but not take them away. +- Changes survive restarts and version upgrades. +- Nothing is removed invisibly. Every removal appears in a plan that can be read before + it applies. +- New kinds plug in without changing the commands or the file format. + +## Non-goals + +- Managing user data (organizations, projects, groups, users, policies). People manage + those through the product. A file would fight them. +- Renaming a role's internal name. See decision 5. +- Deciding how deployments store their files or trigger runs. This RFC defines the + commands and their behavior; each installation wires them into its own process. + +## Design + +### The file format + +A desired-state file holds one or more YAML documents. Each document names a kind and +carries a spec: + +```yaml +kind: PlatformUser +spec: + - type: user + ref: alice@example.org + relation: admin +``` + +A document with a missing spec is rejected, because that is usually a typo. An empty list +must be written as `spec: []`. + +### The commands + +- `frontier reconcile -f [--dry-run] --host -H "Authorization:Basic "` + parses and validates the whole file first, then handles each document in file order. It + prints one report per kind: `no changes`, a plan (dry run), or what was applied. +- `frontier export ` prints the live state of one kind as a desired-state document on + stdout. The kind argument is case-insensitive and accepts a plural. + +Both commands talk to the admin API. They log in with any superuser credential; the +bootstrap service account exists so automation always has one. + +### The framework + +Each kind implements a small interface in `internal/reconcile`: + +- `Reconcile(spec, dryRun)`: read the current state through the API, diff it against the + spec, report the plan, and apply it unless this is a dry run. +- `Export()` (optional): return the current state as a spec value. + +New kinds register in one map in the CLI. The behavior rules every kind must follow are +in the next section. + +## How kinds behave + +### Rules for every kind + +These rules apply to every kind, current and future. A new kind must fit them. Anything +it cannot fit is an exception, and exceptions are written down as clearly as the +predefined-role rules below. + +1. **A kind manages operator-owned objects only.** Objects the server manages are + invisible: the diff skips them, exports omit them, and file entries that target them + are rejected. +2. **Every entry has a stable identity**, like a name or a namespace-plus-name pair. The + identity is how file entries and server objects match. Reconcile never changes an + identity. Renaming something means deleting one entry and creating another. +3. **A listed field is managed. An omitted field keeps its server value.** +4. **Every kind declares what a missing entry means.** There are three allowed answers, + and each kind picks exactly one: + - **The plan fails** — the default. The file must account for every operator-owned + object: keep it, or mark it `delete: true`. Kinds that define structure use this. + - **Removal** — only for kinds whose purpose is controlling a small access list, + where absence meaning removal is the feature. + - **Reset to the server default** — only for settings-like kinds where every entry + always has a value and "not managed" has a well-defined default. + Silently skipping an operator-owned object is not an allowed answer. Drift must + either fail the plan or show up in it. +5. **Destructive deletion needs an explicit `delete: true`**, unless the kind picked + removal-by-absence in rule 4. A kind whose API has no delete must define what the + flag means (for example, deactivate) or reject it. +6. **Validation runs before anything applies.** One bad entry fails the whole document. +7. **Adds and updates run before removes.** A half-finished apply can leave extra access + for a moment, but never less than intended. On a failure mid-apply, the report still + shows what was applied. +8. **Kinds apply in dependency order** when several files are applied together: an + object must exist before another kind references it. For the kinds proposed here, + that means permissions before roles. +9. **Every kind implements export.** Output is sorted and deterministic, omits anything + that matches server defaults, and round-trips: reconciling an export's output always + plans no changes. + +### The first three kinds + +PlatformUser is shipped; Permission and Role are proposed with the behavior below. The +table shows how the three fill in rules 4 and 5; the sections after it cover only what is +specific to each kind. + +| Case | PlatformUser | Permission | Role (custom) | Role (predefined) | +|---|---|---|---|---| +| Exists on server, not in the file | **Access removed** | **Plan fails** | **Plan fails** | Left alone | +| In the file, not on the server | Added | Created | Created | Plan fails (boot creates these) | +| In both, but different | Relation set adjusted | Cannot differ (identity only) | Listed fields updated | Listed fields updated | +| How to delete | Remove the entry | Entry with `delete: true` | Entry with `delete: true` | Not allowed | +| Fields that can change | The relation set | None | `title`, `permissions`, `scopes` | `title`, `permissions`, `scopes` | + +### PlatformUser + +An entry is `{type, ref, relation}`: a user (by email or id) or a service user (by id), +with relation `admin` or `member`. + +- Picks **removal** for a missing entry: the file is the full access list, and removal is + the point of the kind. The list is small and easy to review. +- The diff works per person and per relation. Someone with both `admin` and `member` has + two entries, so changing one relation never touches the other. +- Adding a user by an email that does not exist creates that user. +- The bootstrap service account is server-managed (rule 1). + +### Permission + +An entry is `{namespace, name}`, for example `compute/order` + `get`. A permission is only +an identity, so it has no updatable fields: it is created or deleted. + +- Picks **the plan fails** for a missing entry, because deleting a permission also strips + it from every role that grants it. +- Creating a permission also creates its namespace and updates the SpiceDB schema, so it + is usable in roles right away, on all server pods at once. +- The server adds two delete guards of its own: it refuses to delete a permission that a + boot config file still defines (it would come back on restart), and it refuses to + delete the last permission of a namespace that still has live relationships. +- Base-schema permissions (`app/...` namespaces) are server-managed (rule 1). Names are + alphanumeric; namespaces have exactly two parts (`service/resource`). + +### Role + +An entry is `{name, title, permissions, scopes, delete}`. The name is the identity. Only +platform-level roles are covered; organization-scoped roles are user data. + +- Custom roles pick **the plan fails** for a missing entry. A new custom role must list + its permissions. Permission references may use any format the server knows (`slug`, + `service/resource:verb`, `service.resource.verb`); slugs are canonical, and the + permission list compares as a set. +- **Predefined roles are the one exception to rule 1**: they are server-managed, but a + listed predefined role becomes an override for exactly the fields it lists (a different + title, an extended permission set). Unlisted ones are left alone. Deleting one is + rejected — boot would recreate it — and a listed one must already exist, since boot + creates them. +- The server refuses to delete a role that any policy still binds, with a clear "role is + in use" error. Unbind first, then delete. +- Roles the reconciler creates or updates carry `managed_by: frontier-reconcile` in their + metadata, so their origin stays visible. + +**What a role permission change actually does.** A role's permissions live as data +relationships in SpiceDB, one per permission the role grants. An update swaps that set in +one step. Every principal holding the role gains or loses the permission at that moment, +across all server pods, with no restart. + +**One warning when narrowing roles.** The SpiceDB schema also grants some permissions +directly, outside roles. For example, organization `get` is also granted to the org's +`member` and `owner` relations and to platform superusers. Removing `get` from a role does +not block someone who also holds one of those direct grants. Role-only permissions (like +`rolemanage`, `policymanage`, and all custom permissions) always flip cleanly. Before +narrowing a predefined role, check the resource's permission line in `base_schema.zed`. + +### Future kinds + +A new kind starts from the rules above and writes down its two choices before any code: +what a missing entry means (rule 4) and what deletion means (rule 5). The likely next +kinds map cleanly: platform settings would reset missing entries to their defaults; +webhook endpoints would fail the plan and delete by flag; plan-like resources whose API +only archives would either treat the delete flag as deactivation or reject it. A kind +that cannot express its behavior within these rules needs its exception written into +this document first. + +## Server-side changes + +Two boot behaviors change to make this flow work. The first already shipped; the second +is part of this proposal. + +1. **Superuser promotion from config is gone** (shipped in v0.108.0). The server no longer + reads `app.admin.users`. The only config-driven account is the bootstrap service + account, and the server guards it: its platform access cannot be removed, its + credential is created once and recovered idempotently, and API errors about it stay + generic. +2. **Boot stops updating existing roles** (proposed; draft in #1737). Today boot writes + every predefined role's definition back on each boot, which reverts any change made + through the API. With this change, boot creates a role only when it is missing and + never touches an existing one. + +The second change has a trade-off, accepted knowingly: when a new Frontier version changes +a predefined role's default permissions, existing installations do not pick that up on +their own anymore. Only fresh installs get the new defaults. Release notes must call out +predefined-role changes, and operators roll them out through their desired-state files. A +brand-new installation works the same way: boot installs plain defaults, and the first +reconcile brings it to the desired state. + +## Decisions and reasons + +1. **Three different rules for a missing entry.** Platform users: removed, because + controlling that list is the kind's purpose and the list is small. Permissions and + custom roles: the plan fails, because their removal has a bigger blast radius, and + failing shows drift instead of hiding it. Predefined roles: left alone, because they + always exist and listing all of them is noise. +2. **Deleting is always an explicit flag** for permissions and roles. Nothing structural + is removed because a line disappeared from a file. +3. **Adds and updates before removes**, in every kind. Partial failure must never leave + less access than intended. +4. **Seed files from exports, not by hand.** Exports capture drift that config files never + see. Reconciling an export plans no changes, which makes a kind's first run safe by + construction. +5. **No role renames.** The server looks predefined roles up by name in more than a dozen + live code paths (owner constraint checks, group creation, invitation acceptance, + service user creation, admin listings, notifications). Renaming the name would break + them all. The display `title` is the thing to rename, and the file can manage it. +6. **Permissions apply before roles** when both change: roles reference permission slugs, + so the permissions must exist first. Deleting a permission cascades out of the roles + that grant it, so the reverse order is not needed for removal. +7. **Reads and writes use the public API**, not the database. The reconciler is an API + client like any other: every change is validated, audited, and visible to all pods at + once. It also means the CLI works against any reachable Frontier, old or new. + +## Alternatives considered + +- **Keep the boot-time loaders.** They cannot remove anything, cannot preview, and couple + a config typo to a failed boot. This flow exists to replace them. +- **A combined file format for all kinds in one document.** The multi-document format + already allows it; the per-kind interface keeps each kind's rules separate either way. +- **A marker-based boot skip** (only skip roles a reconcile has touched, keep updating the + rest). It preserves default-permission updates on upgrades for untouched roles. The + blanket skip won because it is simpler to reason about: boot never edits existing roles, + full stop. The marker remains a small follow-up if the lost upgrade flow ever hurts. +- **Renaming predefined role names via a mapping.** Broken by the name lookups in + decision 5. Would need a large refactor of the membership, invitation, and notification + paths. Titles cover the real need. + +## Drawbacks and accepted trade-offs + +- Predefined role permission updates from new versions stop flowing to existing + installations on their own. Desired-state files are how they roll out now. +- A failed apply can stop between adds and removes. The report shows exactly how far it + got, and adds-first ordering keeps the failure mode on the safe side. +- The reconciler trusts the file. Review of the file and of the dry-run plan is the + safety net; the commands do not ask for confirmation themselves. + +## Future work + +- More kinds are natural fits: platform preferences (upsert-only, reset-to-default on + removal), webhooks (full lifecycle), and billing plans (replacing the boot-time plans + loader). +- Metaschemas cannot become a kind yet: the server caches them per pod at boot, so an API + change does not reach other pods. The cache needs a refresh or read-through first. +- Hardening how the CLI receives the auth header, so it is not visible in the process + list. + +## References + +- #1719: PlatformUser kind, the reconcile framework, and the bootstrap service account + (shipped in v0.108.0). +- #1731: the `export` command and stdout fixes (draft for this proposal). +- #1737: the Permission and Role kinds and the boot change for existing roles (draft for + this proposal; changes with this RFC's review). +- CLI reference: `docs/content/docs/reference/cli.mdx`, the `reconcile` and `export` + entries. From bec67f6b5eab309ae4e38c3926b2dc3487f1aa8b Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 10:15:53 +0530 Subject: [PATCH 02/13] docs: export is part of the kind contract in RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 0c23c2199..b2cda11ec 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -100,7 +100,8 @@ Each kind implements a small interface in `internal/reconcile`: - `Reconcile(spec, dryRun)`: read the current state through the API, diff it against the spec, report the plan, and apply it unless this is a dry run. -- `Export()` (optional): return the current state as a spec value. +- `Export()`: return the current state as a spec value. Part of the contract, not + optional — rule 9 depends on every kind having it. New kinds register in one map in the CLI. The behavior rules every kind must follow are in the next section. From 76e2d265bf18f90ebdfb2cd311ca992c047f1cd7 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 11:46:32 +0530 Subject: [PATCH 03/13] docs: address RFC 0001 review round one --- docs/rfcs/0001-declarative-reconcile.md | 74 +++++++++++++++++-------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index b2cda11ec..d18e0c508 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -29,10 +29,11 @@ changes with it. | **Operator** | A person who runs a Frontier installation: edits desired-state files, reviews plans, and applies them. Different from a product user, who uses Frontier through applications. | | **Kind** | One type of managed resource, for example `PlatformUser` or `Role`. Each kind has its own file format and its own rules. | | **Reconcile** | Compare a desired-state file with the live server and make the server match. Runs through the admin API. | -| **Platform user** | A user or service account with platform-wide access: relation `admin` (superuser) or `member`. | +| **Platform user** | A user or service user with platform-wide access: relation `admin` (superuser) or `member`. | +| **Service user** | A machine account (what other systems call a service account). Owned by an organization, authenticates with a client id and secret. | | **Predefined role** | One of the roles built into Frontier (`app_organization_owner`, `app_project_viewer`, ...). Boot creates them when missing. Other Frontier code looks them up by name, so their names must never change. | | **Custom role** | A platform-level role an operator created. Fully owned by the desired-state file. | -| **Bootstrap service account** | The automation superuser the server creates at boot from `app.admin.bootstrap`. Meant for tooling like this. The server guards it, and the reconciler refuses to touch it. | +| **Bootstrap service user** | The automation superuser the server creates at boot from `app.admin.bootstrap`. Meant for tooling like this. The server guards it, and the reconciler refuses to touch it. | ## Problem @@ -69,10 +70,11 @@ Frontier's platform configuration was managed in three ways. All three had probl ### The file format -A desired-state file holds one or more YAML documents. Each document names a kind and -carries a spec: +A desired-state file holds one or more YAML documents. Each document names the format +version and a kind, and carries a spec: ```yaml +apiVersion: v1 kind: PlatformUser spec: - type: user @@ -80,19 +82,25 @@ spec: relation: admin ``` -A document with a missing spec is rejected, because that is usually a typo. An empty list -must be written as `spec: []`. +`apiVersion` exists so a future change to a kind's spec shape can be told apart from old +files. A document without it is read as `v1`, so files written before the field existed +keep working; an unknown version is rejected. A document with a missing spec is rejected, +because that is usually a typo. An empty list must be written as `spec: []`. ### The commands - `frontier reconcile -f [--dry-run] --host -H "Authorization:Basic "` - parses and validates the whole file first, then handles each document in file order. It - prints one report per kind: `no changes`, a plan (dry run), or what was applied. + parses and checks the whole file first — version known, every kind registered, every + spec present — so a malformed later document stops the run before anything applies. + Documents then dispatch in file order. It prints one report per kind: `no changes`, a + plan (dry run), or what was applied. - `frontier export ` prints the live state of one kind as a desired-state document on stdout. The kind argument is case-insensitive and accepts a plural. Both commands talk to the admin API. They log in with any superuser credential; the -bootstrap service account exists so automation always has one. +bootstrap service user exists so automation always has one. The `-H` flag is an interim +way to pass the token: process listings can see command arguments, so automation must +mask the token in its logs, and a file or environment input is planned (see future work). ### The framework @@ -100,8 +108,8 @@ Each kind implements a small interface in `internal/reconcile`: - `Reconcile(spec, dryRun)`: read the current state through the API, diff it against the spec, report the plan, and apply it unless this is a dry run. -- `Export()`: return the current state as a spec value. Part of the contract, not - optional — rule 9 depends on every kind having it. +- `Export()`: return the current state as a spec value. Part of the same interface, so a + kind cannot be registered without it — rule 9 depends on every kind having it. New kinds register in one map in the CLI. The behavior rules every kind must follow are in the next section. @@ -134,13 +142,20 @@ predefined-role rules below. 5. **Destructive deletion needs an explicit `delete: true`**, unless the kind picked removal-by-absence in rule 4. A kind whose API has no delete must define what the flag means (for example, deactivate) or reject it. -6. **Validation runs before anything applies.** One bad entry fails the whole document. +6. **Validation runs before anything applies.** The whole file is parsed and checked + before the first document dispatches, and one bad entry fails its whole document. + Documents in one file can still contradict each other — for example, a Permission + document deletes a permission that the Role document still grants. Cross-document + checks close this: a role's permission references must resolve against the server + state, plus permissions this file creates, minus permissions this file deletes. 7. **Adds and updates run before removes.** A half-finished apply can leave extra access for a moment, but never less than intended. On a failure mid-apply, the report still shows what was applied. -8. **Kinds apply in dependency order** when several files are applied together: an - object must exist before another kind references it. For the kinds proposed here, - that means permissions before roles. +8. **Dependency order inside a file is the author's job.** The tool applies documents in + the order they appear and does not reorder them; an object must exist before another + document references it, so permissions go before roles. Validation should reject a + known-bad order (a Role document before a Permission document) so a wrong file fails + fast instead of failing mid-apply. 9. **Every kind implements export.** Output is sorted and deterministic, omits anything that matches server defaults, and round-trips: reconciling an export's output always plans no changes. @@ -169,7 +184,7 @@ with relation `admin` or `member`. - The diff works per person and per relation. Someone with both `admin` and `member` has two entries, so changing one relation never touches the other. - Adding a user by an email that does not exist creates that user. -- The bootstrap service account is server-managed (rule 1). +- The bootstrap service user is server-managed (rule 1). ### Permission @@ -210,12 +225,19 @@ relationships in SpiceDB, one per permission the role grants. An update swaps th one step. Every principal holding the role gains or loses the permission at that moment, across all server pods, with no restart. -**One warning when narrowing roles.** The SpiceDB schema also grants some permissions -directly, outside roles. For example, organization `get` is also granted to the org's -`member` and `owner` relations and to platform superusers. Removing `get` from a role does -not block someone who also holds one of those direct grants. Role-only permissions (like -`rolemanage`, `policymanage`, and all custom permissions) always flip cleanly. Before +**One warning when narrowing roles.** The SpiceDB schema also grants permissions +directly, outside roles: almost every predefined organization permission carries a +direct `owner` grant, several carry `member`, and all carry the platform superuser path. +An org owner keeps `rolemanage`, `policymanage`, `delete`, and the rest no matter what +the owner role says, because every membership path pairs the role with the `owner` +relation. So narrowing a predefined role restricts only principals whose access comes +purely through the role — verified live: after narrowing `app_organization_owner`, a +role-only holder was denied while a real owner kept everything. Only custom permissions +flip cleanly for everyone, because they are granted purely through role paths. Before narrowing a predefined role, check the resource's permission line in `base_schema.zed`. +Once ownership moves fully to roles and the direct relation grants come out of the base +schema, reconcile will control predefined-role access completely; that migration is out +of scope here, but it is why the limitation exists and where it goes. ### Future kinds @@ -272,7 +294,9 @@ reconcile brings it to the desired state. that grant it, so the reverse order is not needed for removal. 7. **Reads and writes use the public API**, not the database. The reconciler is an API client like any other: every change is validated, audited, and visible to all pods at - once. It also means the CLI works against any reachable Frontier, old or new. + once. Each kind needs the server APIs and behavior it depends on, so each kind states + its minimum server version: PlatformUser needs v0.108.0, and Permission and Role need + the release that ships this proposal (the boot change is what makes role edits stick). ## Alternatives considered @@ -296,6 +320,10 @@ reconcile brings it to the desired state. got, and adds-first ordering keeps the failure mode on the safe side. - The reconciler trusts the file. Review of the file and of the dry-run plan is the safety net; the commands do not ask for confirmation themselves. +- Concurrent runs are last-write-wins. Read, diff, and apply are not atomic, so two + reconciles racing each other — or one racing a manual admin change — can overwrite each + other. This is acceptable because the flow is declarative: re-running the same file + converges to the same end state, so a lost race is corrected by the next apply. ## Future work @@ -309,7 +337,7 @@ reconcile brings it to the desired state. ## References -- #1719: PlatformUser kind, the reconcile framework, and the bootstrap service account +- #1719: PlatformUser kind, the reconcile framework, and the bootstrap service user (shipped in v0.108.0). - #1731: the `export` command and stdout fixes (draft for this proposal). - #1737: the Permission and Role kinds and the boot change for existing roles (draft for From 3ae71e2df894863d603dcc286acd1ede27756a5a Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 11:51:59 +0530 Subject: [PATCH 04/13] docs: state the planned removal of relation-based ownership in RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index d18e0c508..f64d0f14b 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -235,9 +235,11 @@ purely through the role — verified live: after narrowing `app_organization_own role-only holder was denied while a real owner kept everything. Only custom permissions flip cleanly for everyone, because they are granted purely through role paths. Before narrowing a predefined role, check the resource's permission line in `base_schema.zed`. -Once ownership moves fully to roles and the direct relation grants come out of the base -schema, reconcile will control predefined-role access completely; that migration is out -of scope here, but it is why the limitation exists and where it goes. +This limitation is temporary: relation-based ownership is planned for removal. The +direct `owner` and `member` grants come out of the base schema, and ownership is granted +through roles instead. From then on, removing a permission from a predefined role +restricts everyone holding it, exactly as expected. That migration is its own piece of +work (see future work), but it is why the limitation exists and where it goes. ### Future kinds @@ -334,6 +336,10 @@ reconcile brings it to the desired state. change does not reach other pods. The cache needs a refresh or read-through first. - Hardening how the CLI receives the auth header, so it is not visible in the process list. +- Removing relation-based ownership from the base schema: the direct `owner` and + `member` grants go away and ownership is granted through roles. This makes + predefined-role permission changes apply to everyone, including org owners, and + removes the narrowing limitation described in the Role section. ## References From 2a8a57dbcb4c0ad6f390a2304956a45e47f3813d Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 12:11:46 +0530 Subject: [PATCH 05/13] docs: deprecate the boot-time resources loader in RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index f64d0f14b..e2814cbab 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -196,8 +196,9 @@ an identity, so it has no updatable fields: it is created or deleted. - Creating a permission also creates its namespace and updates the SpiceDB schema, so it is usable in roles right away, on all server pods at once. - The server adds two delete guards of its own: it refuses to delete a permission that a - boot config file still defines (it would come back on restart), and it refuses to - delete the last permission of a namespace that still has live relationships. + boot config file still defines (it would come back on restart; this guard lives only as + long as the deprecated loader — see server-side changes), and it refuses to delete the + last permission of a namespace that still has live relationships. - Base-schema permissions (`app/...` namespaces) are server-managed (rule 1). Names are alphanumeric; namespaces have exactly two parts (`service/resource`). @@ -273,6 +274,21 @@ predefined-role changes, and operators roll them out through their desired-state brand-new installation works the same way: boot installs plain defaults, and the first reconcile brings it to the desired state. +### Deprecating the boot-time loader + +Leaving `resources_config_path` alive would give the same objects two owners: a +permission defined in a boot file can never be deleted through reconcile until someone +also edits that file and restarts, and a role the boot file created can be changed by +reconcile, after which the file no longer describes the server. So the loader goes: + +- `resources_config_path` is deprecated as of this RFC and removed after two minor + versions. +- Migration is one export: run `frontier export permission` and `frontier export role`, + commit the output as the desired-state files, and drop `resources_config_path` from + the server config. +- The permission delete guard that checks the boot files stays for the deprecation + window and goes away with the loader. + ## Decisions and reasons 1. **Three different rules for a missing entry.** Platform users: removed, because From 6b121722c7a5536854477f719b40b8d2aceaa4c8 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 15:13:15 +0530 Subject: [PATCH 06/13] docs: complete the apiVersion story in RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index e2814cbab..3bfd94983 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -84,8 +84,11 @@ spec: `apiVersion` exists so a future change to a kind's spec shape can be told apart from old files. A document without it is read as `v1`, so files written before the field existed -keep working; an unknown version is rejected. A document with a missing spec is rejected, -because that is usually a typo. An empty list must be written as `spec: []`. +keep working; an unknown version is rejected. The version belongs to the document, so one +kind's spec shape can change without touching the files of other kinds. `frontier export` +always writes the field, so any file regenerated from an export carries it. A document +with a missing spec is rejected, because that is usually a typo. An empty list must be +written as `spec: []`. ### The commands From fcce51d8688b091e6e9de1d2585c3a0bb171b2a5 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 14 Jul 2026 16:08:52 +0530 Subject: [PATCH 07/13] docs: keep kinds independent, server enforces cross-kind references at apply --- docs/rfcs/0001-declarative-reconcile.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 3bfd94983..112b097e5 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -145,12 +145,15 @@ predefined-role rules below. 5. **Destructive deletion needs an explicit `delete: true`**, unless the kind picked removal-by-absence in rule 4. A kind whose API has no delete must define what the flag means (for example, deactivate) or reject it. -6. **Validation runs before anything applies.** The whole file is parsed and checked - before the first document dispatches, and one bad entry fails its whole document. - Documents in one file can still contradict each other — for example, a Permission - document deletes a permission that the Role document still grants. Cross-document - checks close this: a role's permission references must resolve against the server - state, plus permissions this file creates, minus permissions this file deletes. +6. **Validation runs before anything applies, and kinds stay independent.** The whole + file is parsed and checked before the first document dispatches, and one bad entry + fails its whole document. Kinds do not validate against each other: a reconciler + never resolves another kind's references, so documents can contradict each other — + for example, a Permission document deletes a permission that a Role document still + grants. The server is the enforcement point: the contradicting API call fails with a + clear error, the run stops there, and nothing is lost. A dry run cannot see such a + contradiction (it reads the state before the delete), but the next dry run after the + failed apply shows the drift. The trade for this simplicity is accepted on purpose. 7. **Adds and updates run before removes.** A half-finished apply can leave extra access for a moment, but never less than intended. On a failure mid-apply, the report still shows what was applied. From 5c623c54fb8b5d28e25d734ac09833b1537169dc Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 15 Jul 2026 11:14:53 +0530 Subject: [PATCH 08/13] docs: state the no-transactions model and convergence recovery in RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 112b097e5..ce45e1321 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -154,9 +154,12 @@ predefined-role rules below. clear error, the run stops there, and nothing is lost. A dry run cannot see such a contradiction (it reads the state before the delete), but the next dry run after the failed apply shows the drift. The trade for this simplicity is accepted on purpose. -7. **Adds and updates run before removes.** A half-finished apply can leave extra access - for a moment, but never less than intended. On a failure mid-apply, the report still - shows what was applied. +7. **Adds and updates run before removes, and nothing is transactional.** Every API call + commits on its own; a run can stop after any of them, and nothing rolls back. The + adds-first order means a half-finished apply can leave extra access for a moment, but + never less than intended. On a failure mid-apply, the report still shows what was + applied, and recovery is always the same: fix the cause and apply the file again. + What already applied plans as no change, and the run continues from the difference. 8. **Dependency order inside a file is the author's job.** The tool applies documents in the order they appear and does not reorder them; an object must exist before another document references it, so permissions go before roles. Validation should reject a @@ -321,6 +324,14 @@ reconcile, after which the file no longer describes the server. So the loader go once. Each kind needs the server APIs and behavior it depends on, so each kind states its minimum server version: PlatformUser needs v0.108.0, and Permission and Role need the release that ships this proposal (the boot change is what makes role edits stick). +8. **Convergence instead of transactions.** The API has no way to group changes into one + atomic unit, and building one (a staging area, a rollback log) would be a large piece + of machinery. We chose not to. Instead, the file is declarative — it describes the end + state, not steps — and applying it is idempotent, so it is safe to apply any number of + times. Whatever a failed or interrupted run left behind, the next run picks up exactly + the remaining difference and converges to the desired state. Re-running the file is + the one recovery procedure for every failure mode: a crashed run, a rejected API call, + or a lost race with another writer. ## Alternatives considered @@ -340,8 +351,10 @@ reconcile, after which the file no longer describes the server. So the loader go - Predefined role permission updates from new versions stop flowing to existing installations on their own. Desired-state files are how they roll out now. -- A failed apply can stop between adds and removes. The report shows exactly how far it - got, and adds-first ordering keeps the failure mode on the safe side. +- A failed apply can stop between adds and removes — there are no transactions and no + rollback. The report shows exactly how far it got, adds-first ordering keeps the + failure mode on the safe side, and applying the file again converges to the desired + state (decision 8). - The reconciler trusts the file. Review of the file and of the dry-run plan is the safety net; the commands do not ask for confirmation themselves. - Concurrent runs are last-write-wins. Read, diff, and apply are not atomic, so two From 531a7e40d4081813b0dcf522719225e6f5424547 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 15 Jul 2026 11:21:41 +0530 Subject: [PATCH 09/13] docs: add reconcile run diagram to RFC 0001 --- docs/rfcs/0001-declarative-reconcile.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index ce45e1321..3698c0985 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -117,6 +117,30 @@ Each kind implements a small interface in `internal/reconcile`: New kinds register in one map in the CLI. The behavior rules every kind must follow are in the next section. +One reconcile run, end to end: + +```mermaid +flowchart TD + FILE[Desired-state file] --> PARSE["Parse and check the whole file: + apiVersion known, kinds registered, specs present"] + PARSE -- any problem --> STOP[Fail before anything applies] + PARSE --> DOC[Next document, in file order] + DOC --> VAL[Validate every entry in the spec] + VAL --> FETCH[Read current state through the admin API] + FETCH --> DIFF[Diff current against desired] + DIFF --> PLAN["Plan: adds and updates first, removes last"] + PLAN -- dry run --> PRINT[Print the plan, change nothing] + PLAN -- apply --> OPS["Apply each op as one API call + (no transactions, no rollback)"] + OPS -- an op fails --> REPORT["Stop; report what applied. + Fix and re-run: it converges"] + OPS --> DONE[Report applied ops] + DONE --> DOC +``` + +Export is the same loop in reverse: the kind reads current state through the API and +prints it as a document that reconciling would leave unchanged. + ## How kinds behave ### Rules for every kind From 9e3362d433ce14f28344cda1dbd3d4ec5208af70 Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 15 Jul 2026 14:38:10 +0530 Subject: [PATCH 10/13] docs(rfc): predefined roles pick reset-to-default; unlisted roles converge to their definitions --- docs/rfcs/0001-declarative-reconcile.md | 80 ++++++++++++++++++------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 3698c0985..ff60a52c8 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -155,15 +155,18 @@ predefined-role rules below. 2. **Every entry has a stable identity**, like a name or a namespace-plus-name pair. The identity is how file entries and server objects match. Reconcile never changes an identity. Renaming something means deleting one entry and creating another. -3. **A listed field is managed. An omitted field keeps its server value.** +3. **A listed field is managed. An omitted field keeps its server value.** A kind that + resets to defaults (rule 4) declares a different fallback: there, an omitted field + takes the default value. The predefined-role rules below do this. 4. **Every kind declares what a missing entry means.** There are three allowed answers, and each kind picks exactly one: - **The plan fails** — the default. The file must account for every operator-owned object: keep it, or mark it `delete: true`. Kinds that define structure use this. - **Removal** — only for kinds whose purpose is controlling a small access list, where absence meaning removal is the feature. - - **Reset to the server default** — only for settings-like kinds where every entry - always has a value and "not managed" has a well-defined default. + - **Reset to the default** — only where every object always has a value and "not + managed" has a well-defined default to return to: settings-like kinds, and + predefined roles. Silently skipping an operator-owned object is not an allowed answer. Drift must either fail the plan or show up in it. 5. **Destructive deletion needs an explicit `delete: true`**, unless the kind picked @@ -201,9 +204,9 @@ specific to each kind. | Case | PlatformUser | Permission | Role (custom) | Role (predefined) | |---|---|---|---|---| -| Exists on server, not in the file | **Access removed** | **Plan fails** | **Plan fails** | Left alone | +| Exists on server, not in the file | **Access removed** | **Plan fails** | **Plan fails** | **Reset to its definition** | | In the file, not on the server | Added | Created | Created | Plan fails (boot creates these) | -| In both, but different | Relation set adjusted | Cannot differ (identity only) | Listed fields updated | Listed fields updated | +| In both, but different | Relation set adjusted | Cannot differ (identity only) | Listed fields updated | Listed fields override the definition; omitted fields reset to it | | How to delete | Remove the entry | Entry with `delete: true` | Entry with `delete: true` | Not allowed | | Fields that can change | The relation set | None | `title`, `permissions`, `scopes` | `title`, `permissions`, `scopes` | @@ -244,11 +247,23 @@ platform-level roles are covered; organization-scoped roles are user data. its permissions. Permission references may use any format the server knows (`slug`, `service/resource:verb`, `service.resource.verb`); slugs are canonical, and the permission list compares as a set. -- **Predefined roles are the one exception to rule 1**: they are server-managed, but a - listed predefined role becomes an override for exactly the fields it lists (a different - title, an extended permission set). Unlisted ones are left alone. Deleting one is - rejected — boot would recreate it — and a listed one must already exist, since boot - creates them. +- **Predefined roles are the one exception to rule 1**: they are server-created, but the + flow manages them, and they pick **reset to the default** for a missing entry. Every + predefined role on the server converges to its shipped definition: a listed entry + overrides exactly the fields it lists (a different title, an extended permission set), + an omitted field takes the definition's value (the rule 3 exception), and a role with + no entry at all resets fully. The plan marks those updates with "not in file, reset to + default", so removing an entry from the file is a visible action, not a silent one. + Deleting a predefined role is rejected — boot would recreate it — and a listed one + must already exist, since creating them is boot's job. +- Two edges of the reset, both consequences of the file format. A field whose server + value is empty (a row from before the `scopes` column, say) converges only when an + entry lists it explicitly: a file cannot hold an empty value, so treating it as + drift would break the export round trip (rule 9). And the definitions are the ones + compiled into the CLI, so the reconcile image should match the server's release — a + newer or older image would plan resets toward the wrong defaults. When a release + changes a predefined role's definition, the first run with the new image plans that + change; this is how new defaults roll out (see server-side changes). - The server refuses to delete a role that any policy still binds, with a clear "role is in use" error. Unbind first, then delete. - Roles the reconciler creates or updates carry `managed_by: frontier-reconcile` in their @@ -300,10 +315,11 @@ is part of this proposal. through the API. With this change, boot creates a role only when it is missing and never touches an existing one. -The second change has a trade-off, accepted knowingly: when a new Frontier version changes -a predefined role's default permissions, existing installations do not pick that up on -their own anymore. Only fresh installs get the new defaults. Release notes must call out -predefined-role changes, and operators roll them out through their desired-state files. A +The definition-update job boot loses does not disappear — it moves to reconcile. When a +new Frontier version changes a predefined role's definition, the first reconcile run with +the new release's image plans that change for every predefined role the file does not +override, a human reads the plan, and applying it converges the installation. The same +update that used to happen invisibly at every restart now happens once, reviewed. A brand-new installation works the same way: boot installs plain defaults, and the first reconcile brings it to the desired state. @@ -327,8 +343,10 @@ reconcile, after which the file no longer describes the server. So the loader go 1. **Three different rules for a missing entry.** Platform users: removed, because controlling that list is the kind's purpose and the list is small. Permissions and custom roles: the plan fails, because their removal has a bigger blast radius, and - failing shows drift instead of hiding it. Predefined roles: left alone, because they - always exist and listing all of them is noise. + failing shows drift instead of hiding it. Predefined roles: reset to their + definitions, because they always exist and have a well-defined default to return + to — listing all of them would be noise, so "not listed" means "at the defaults", + and any drift shows up in the plan instead of being skipped. 2. **Deleting is always an explicit flag** for permissions and roles. Nothing structural is removed because a line disappeared from a file. 3. **Adds and updates before removes**, in every kind. Partial failure must never leave @@ -348,6 +366,8 @@ reconcile, after which the file no longer describes the server. So the loader go once. Each kind needs the server APIs and behavior it depends on, so each kind states its minimum server version: PlatformUser needs v0.108.0, and Permission and Role need the release that ships this proposal (the boot change is what makes role edits stick). + For Role, the CLI image should match the server release too: it carries the + predefined-role definitions that the reset converges to. 8. **Convergence instead of transactions.** The API has no way to group changes into one atomic unit, and building one (a staging area, a rollback log) would be a large piece of machinery. We chose not to. Instead, the file is declarative — it describes the end @@ -364,17 +384,31 @@ reconcile, after which the file no longer describes the server. So the loader go - **A combined file format for all kinds in one document.** The multi-document format already allows it; the per-kind interface keeps each kind's rules separate either way. - **A marker-based boot skip** (only skip roles a reconcile has touched, keep updating the - rest). It preserves default-permission updates on upgrades for untouched roles. The - blanket skip won because it is simpler to reason about: boot never edits existing roles, - full stop. The marker remains a small follow-up if the lost upgrade flow ever hurts. + rest). It would preserve default-permission updates on upgrades for untouched roles. + The blanket skip won because it is simpler to reason about — boot never edits existing + roles, full stop — and the reset-to-default behavior carries the upgrade flow through + reconcile instead, so nothing is lost. +- **Leaving unlisted predefined roles alone** instead of resetting them. Safer-looking — + removing a line could never change access — but it silently skips drifted roles, cuts + off definition updates from new releases, and makes "reset to defaults" inexpressible + except by writing every default out by hand. The reset keeps every change inside a + reviewable plan, which is the protection this flow actually relies on. - **Renaming predefined role names via a mapping.** Broken by the name lookups in decision 5. Would need a large refactor of the membership, invitation, and notification paths. Titles cover the real need. ## Drawbacks and accepted trade-offs -- Predefined role permission updates from new versions stop flowing to existing - installations on their own. Desired-state files are how they roll out now. +- Predefined role definition updates from new versions no longer apply at boot; they + wait for the next reconcile run with the new release's image. Until someone runs it, + an upgraded installation keeps the old role definitions. +- Removing a predefined role's entry from the file is an action: the role resets to its + definition on the next run, which can widen or narrow live access. The plan marks + these updates ("not in file, reset to default"), but operators must know that for + predefined roles, absence means defaults — the guides say so. +- The reset target is the definition set compiled into the CLI, so the reconcile image + must match the server's release, or plans drift toward the wrong defaults. Serving + the definitions from the server itself would remove this coupling (future work). - A failed apply can stop between adds and removes — there are no transactions and no rollback. The report shows exactly how far it got, adds-first ordering keeps the failure mode on the safe side, and applying the file again converges to the desired @@ -395,6 +429,10 @@ reconcile, after which the file no longer describes the server. So the loader go change does not reach other pods. The cache needs a refresh or read-through first. - Hardening how the CLI receives the auth header, so it is not visible in the process list. +- A read-only API that returns the server's own predefined-role definitions. The CLI + would fetch the reset target from the running server instead of its compiled copy, + removing the image-version coupling and failing cleanly against servers that predate + the Role kind. - Removing relation-based ownership from the base schema: the direct `owner` and `member` grants go away and ownership is granted through roles. This makes predefined-role permission changes apply to everyone, including org owners, and From 3ce47415840417a67459309975885cd091ceb8fc Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 15 Jul 2026 16:38:29 +0530 Subject: [PATCH 11/13] docs(rfc): clarify empty-value reset edge, name-dedup, and the ordering rule wording --- docs/rfcs/0001-declarative-reconcile.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index ff60a52c8..958df03eb 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -189,9 +189,9 @@ predefined-role rules below. What already applied plans as no change, and the run continues from the difference. 8. **Dependency order inside a file is the author's job.** The tool applies documents in the order they appear and does not reorder them; an object must exist before another - document references it, so permissions go before roles. Validation should reject a - known-bad order (a Role document before a Permission document) so a wrong file fails - fast instead of failing mid-apply. + document references it, so a Permission document goes before a Role document. Validation + should reject a file that places a Role document ahead of a Permission document, so a + wrong order fails fast instead of failing mid-apply. 9. **Every kind implements export.** Output is sorted and deterministic, omits anything that matches server defaults, and round-trips: reconciling an export's output always plans no changes. @@ -251,11 +251,13 @@ platform-level roles are covered; organization-scoped roles are user data. flow manages them, and they pick **reset to the default** for a missing entry. Every predefined role on the server converges to its shipped definition: a listed entry overrides exactly the fields it lists (a different title, an extended permission set), - an omitted field takes the definition's value (the rule 3 exception), and a role with - no entry at all resets fully. The plan marks those updates with "not in file, reset to - default", so removing an entry from the file is a visible action, not a silent one. - Deleting a predefined role is rejected — boot would recreate it — and a listed one - must already exist, since creating them is boot's job. + an omitted field takes the definition's value (the rule 3 exception, with one edge for + empty server values described below), and a role with no entry at all resets fully. The + plan marks those updates with "not in file, reset to default", so removing an entry from + the file is a visible action, not a silent one. Deleting a predefined role is rejected — + boot would recreate it — and a listed one must already exist, since creating them is + boot's job. Definitions are matched to roles by name; if the shipped list names one more + than once, the first definition wins, the same rule boot follows. - Two edges of the reset, both consequences of the file format. A field whose server value is empty (a row from before the `scopes` column, say) converges only when an entry lists it explicitly: a file cannot hold an empty value, so treating it as From abcb312fa9e2930702042ff9ef33bc26d157712e Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Wed, 15 Jul 2026 16:51:23 +0530 Subject: [PATCH 12/13] docs(rfc): add role description to the managed fields --- docs/rfcs/0001-declarative-reconcile.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 958df03eb..529d0196c 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -208,7 +208,7 @@ specific to each kind. | In the file, not on the server | Added | Created | Created | Plan fails (boot creates these) | | In both, but different | Relation set adjusted | Cannot differ (identity only) | Listed fields updated | Listed fields override the definition; omitted fields reset to it | | How to delete | Remove the entry | Entry with `delete: true` | Entry with `delete: true` | Not allowed | -| Fields that can change | The relation set | None | `title`, `permissions`, `scopes` | `title`, `permissions`, `scopes` | +| Fields that can change | The relation set | None | `title`, `description`, `permissions`, `scopes` | `title`, `description`, `permissions`, `scopes` | ### PlatformUser @@ -240,8 +240,9 @@ an identity, so it has no updatable fields: it is created or deleted. ### Role -An entry is `{name, title, permissions, scopes, delete}`. The name is the identity. Only -platform-level roles are covered; organization-scoped roles are user data. +An entry is `{name, title, description, permissions, scopes, delete}`. The name is the +identity. Only platform-level roles are covered; organization-scoped roles are user data. +The description is stored in role metadata, and it is managed the same way as the title. - Custom roles pick **the plan fails** for a missing entry. A new custom role must list its permissions. Permission references may use any format the server knows (`slug`, From 351014bc702701bd956e7c4c73fb3d7d5c51b12b Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Thu, 16 Jul 2026 15:08:19 +0530 Subject: [PATCH 13/13] docs(rfc): note unknown-field rejection and metadata-merge on update --- docs/rfcs/0001-declarative-reconcile.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md index 529d0196c..fda83b2ca 100644 --- a/docs/rfcs/0001-declarative-reconcile.md +++ b/docs/rfcs/0001-declarative-reconcile.md @@ -174,7 +174,9 @@ predefined-role rules below. flag means (for example, deactivate) or reject it. 6. **Validation runs before anything applies, and kinds stay independent.** The whole file is parsed and checked before the first document dispatches, and one bad entry - fails its whole document. Kinds do not validate against each other: a reconciler + fails its whole document. An entry with an unknown field is a bad entry: a typo like + `delet: true` fails the plan instead of being silently ignored, which would make a run + quietly do the wrong thing. Kinds do not validate against each other: a reconciler never resolves another kind's references, so documents can contradict each other — for example, a Permission document deletes a permission that a Role document still grants. The server is the enforcement point: the contradicting API call fails with a @@ -270,7 +272,8 @@ The description is stored in role metadata, and it is managed the same way as th - The server refuses to delete a role that any policy still binds, with a clear "role is in use" error. Unbind first, then delete. - Roles the reconciler creates or updates carry `managed_by: frontier-reconcile` in their - metadata, so their origin stays visible. + metadata, so their origin stays visible. An update merges these managed keys over the + role's existing metadata, so any other metadata keys an operator set stay in place. **What a role permission change actually does.** A role's permissions live as data relationships in SpiceDB, one per permission the role grants. An update swaps that set in