diff --git a/docs/rfcs/0001-declarative-reconcile.md b/docs/rfcs/0001-declarative-reconcile.md
new file mode 100644
index 000000000..96e3c4130
--- /dev/null
+++ b/docs/rfcs/0001-declarative-reconcile.md
@@ -0,0 +1,290 @@
+# RFC 0001: Declarative management of platform resources
+
+| | |
+|---|---|
+| **Status** | Accepted. Implemented in the `reconcile` package and the `reconcile` and `export` commands. |
+| **Author** | Rohil Surana |
+| **Created** | 2026-07-09 |
+| **Updated** | 2026-07-23 |
+
+## Summary
+
+Frontier can manage its platform configuration from a file instead of one-off API calls.
+You write down what should exist for one kind of resource. `frontier reconcile` compares the
+file with the live server, prints a plan, and applies the difference through the admin API.
+`frontier export` does the reverse: it prints the live state in the same format, so a file can
+be seeded from a running server.
+
+This document sets the rules every kind follows. The kinds are PlatformUser, Permission, Role,
+Preference, and Webhook. New kinds plug in under the same rules without changing the commands
+or the file format.
+
+## At a glance
+
+The whole tool is one loop:
+
+```mermaid
+flowchart LR
+ file["Desired-state file
one YAML doc per kind"]
+ plan["Plan
the difference
always printed"]
+ server[("Live Frontier server
admin API")]
+
+ file -->|"reconcile reads the file"| plan
+ server -->|"reconcile reads the live state"| plan
+ plan -->|"apply, skipped on a dry run"| server
+ server -.->|"export prints the live state as a file"| file
+```
+
+Each entry is either an **object** or a **value**. That one choice decides what a missing entry
+means and whether you can delete it:
+
+```mermaid
+flowchart TD
+ r["A resource the kind manages"] --> q{"Can reconcile
create and delete it?"}
+ q -->|"yes"| o["Object
Permission, Webhook, custom Role
on the server but not in the file: the plan fails
to delete one, set delete: true"]
+ q -->|"no"| v["Value
PlatformUser, Preference, predefined Role
always exists, so no delete flag
not in the file: back to its default"]
+```
+
+The kinds split cleanly, except Role, which is both: a custom role is an object, a predefined
+role is a value.
+
+| Kind | Object or value | Identity | A missing entry | How to remove |
+|---|---|---|---|---|
+| PlatformUser | value | principal + relation | access removed | leave the entry out |
+| Permission | object | namespace + name | plan fails | set `delete: true` |
+| Role, custom | object | name | plan fails | set `delete: true` |
+| Role, predefined | value | name | reset to the shipped definition | cannot be removed |
+| Preference | value | trait name | reset to the trait default | leave the entry out, it resets |
+| Webhook | object | URL | plan fails | set `delete: true` |
+
+Every kind, current and future, follows the same five rules:
+
+1. **Scope and identity.** A kind manages only the entries and fields it declares. Identity is stable, so a rename is a delete plus a create.
+2. **The file is the desired state.** Each entry is an object or a value. A field you write is the whole value. A field you leave out takes the default. An empty field means empty.
+3. **Check the whole file before changing anything.** Nothing applies until the file passes. A check that needs the server runs later, in the diff.
+4. **Converge, do not transact.** One API call per change, adds and updates before deletes, no rollback. To recover from any failure, fix the cause and run again.
+5. **Export is the reverse of reconcile.** For every state the server can reach, reconciling its export plans zero changes.
+
+The rest of this document is the detail behind that picture.
+
+## Problem
+
+Platform configuration used to be managed in three ways, and all three had problems.
+
+1. **Superusers came from server config** (`app.admin.users`). Adding one meant a config change
+ and a restart. Removing one did not work: the server only promoted, never demoted.
+2. **Custom permissions and roles came from a boot-time file** (`resources_config_path`). It
+ could add and update, never remove. There was no preview. A bad file stopped the server
+ from booting.
+3. **Everything else was set by hand** through the admin API. Nothing recorded what should
+ exist, so changes drifted with no record.
+
+## Goals
+
+- One reviewable file per kind that says what should exist.
+- A plan before every change.
+- Removal that works, and that always shows in the plan first.
+- Changes that survive restarts and upgrades.
+- New kinds that plug in without new commands or a new file format.
+
+## Non-goals
+
+- Managing user data (organizations, projects, groups, users, policies). People manage those
+ through the product, and a file would fight them.
+- Renaming a role's internal name. The server looks predefined roles up by name in many code
+ paths.
+- Deciding how a deployment stores its files or triggers runs. This document defines the
+ commands and their behavior. Each install wires them into its own process.
+
+## The file
+
+A file holds one or more YAML documents. Each names a version and a kind, and carries a spec:
+
+```yaml
+apiVersion: v1
+kind: PlatformUser
+spec:
+ - type: user
+ ref: alice@example.org
+ relation: admin
+```
+
+- A document with no `apiVersion` is read as `v1`. An unknown version is rejected.
+- A document with content but no `kind`, or with a missing spec, is rejected. That is usually a
+ typo. To mean an empty list, write `spec: []`.
+- The whole file is checked before anything applies. Documents then apply in the order they
+ appear.
+
+## The commands
+
+- `frontier reconcile -f [--dry-run]` checks the whole file, prints one plan per kind,
+ and applies it unless this is a dry run.
+- `frontier export ` prints the live state of one kind as a file on stdout.
+
+A reconcile run has three steps. A dry run stops after the plan:
+
+```mermaid
+flowchart TD
+ s1["1. Check the whole file
a bad file stops the run
before any change"]
+ s2["2. Diff each kind against the live server
print one plan per kind
a dry run stops after this"]
+ s3["3. Apply, in file order
adds and updates first, then deletes
one API call per change, no rollback"]
+ fail["If a call fails
fix the cause and run again
what already applied plans no change"]
+
+ s1 --> s2 --> s3
+ s3 -.-> fail
+```
+
+Both log in as a superuser through the admin API. The bootstrap service account exists so
+automation always has one. The `-H` flag passes the token for now. A file or environment input
+is planned (see future work).
+
+## The five rules
+
+These are the one-liners from [At a glance](#at-a-glance), in full.
+
+### 1. Scope and identity
+
+A kind manages a set it declares: which entries, and which fields within an entry. Anything
+outside that set is invisible. The diff skips it, export leaves it out, and a file entry that
+names it is rejected. Values other tools set (webhook headers, extra role metadata, signing
+secrets) pass through writes untouched. Every entry has one stable identity, like a name or a
+URL. Reconcile never changes an identity. A rename is a delete plus a create.
+
+### 2. The file is the desired state
+
+Each entry is an object or a value. The resource decides which. A kind does not pick from a
+menu.
+
+- An **object** is something the flow creates and deletes: a custom role, a permission, a
+ webhook. Every object on the server must appear in the file. One that is missing fails the
+ plan. Deleting one needs `delete: true`. If the API cannot delete, the flag maps to the
+ nearest real action (archive, disable) and the kind says so, or it rejects the flag.
+- A **value** always exists and has a default: a platform grant, a preference, a predefined
+ role's fields. The file sets it. A value the file leaves out goes back to its default. A
+ value has no delete flag. A default must be reachable through the API. If it is not, the plan
+ fails for that entry with a clear message.
+
+One field rule covers both. A field you write is the whole value. A field you leave out takes
+the default. An empty field (`""` or `[]`) means empty. Nothing merges. For an object the
+default is empty, so leaving a field out and writing it empty come to the same thing. They
+differ only where the default is not empty: a predefined role's fields, a preference.
+
+A plan never contains a change that cannot apply, and never skips a managed entry in silence.
+Drift either shows in the plan or fails it.
+
+### 3. Check the whole file before changing anything
+
+Nothing applies until the file passes: a known version, a registered kind (a missing or
+misspelled kind is an error, not a skip), a present spec, correct order, no unknown fields, and
+every entry valid. Entry checks must not need the server. A check that needs live state (does
+this permission exist, is this trait known) runs during the diff, where it shows in the plan or
+fails it.
+
+### 4. Converge, do not transact
+
+Each change is one API call. Adds and updates run before deletes, so a run that stops halfway
+never leaves less access than intended. Nothing rolls back. Recovery from any failure is the
+same: fix the cause and run again. What already applied shows no change, and the run picks up
+the rest. Two runs racing each other, or a run racing a hand change, are last-write-wins, and
+the next run corrects it.
+
+### 5. Export is the reverse of reconcile
+
+`frontier export` writes the current state as a file: sorted, and carrying only the fields that
+differ from their default. The guarantee is over server states, not files: for every state the
+server can reach, reconciling its export must plan zero changes. A check that would reject the
+export of a state the server can reach is a bug in the kind.
+
+## The kinds
+
+The table in [At a glance](#at-a-glance) maps each kind to its type and behavior. Only what is
+specific to each kind follows.
+
+**PlatformUser.** An entry is `{type, ref, relation}`: a user (by email or id) or a service
+user (by id), with relation `admin` or `member`. The file is the full access list, so a missing
+entry removes that access. A user with both relations has two entries. Adding a user by an
+email that does not exist creates the user. The bootstrap service account is server-managed:
+the flow skips it on the server side and rejects it in the file.
+
+**Permission.** An entry is `{namespace, name}`, for example `compute/order` + `get`. A
+permission is an identity only, so it is created or deleted, never updated. Creating one also
+updates the authorization schema, so it is usable in roles right away, on every pod at once. The
+base `app` namespaces are server-managed.
+
+**Role.** An entry is `{name, title, description, permissions, scopes, delete}`. The name is the
+identity. Custom roles are objects. Predefined roles are values whose default is the shipped
+definition. Both run through the one field rule: a field you write replaces, a field you leave
+out takes the default. So an omitted field clears a custom role and returns a predefined role to
+its definition. A predefined role you do not list at all resets fully. Predefined roles cannot
+be created or deleted here; boot owns that. One caveat: narrowing a predefined role only
+restricts principals whose access comes through the role, because the base schema also grants
+owners directly. Removing that direct grant is future work.
+
+**Preference.** An entry is `{name, value}`. Values are strings. A preference the file leaves
+out resets to its trait default. There is no delete flag, because reset is what removal means
+here.
+
+**Webhook.** An entry is `{url, description, subscribed_events, state, delete}`. The URL is the
+identity. An empty event set means all events, which is the server default. The signing secret
+is server-owned: the server makes it on create and never returns it, so it is never in the
+file, a plan, or an export.
+
+## Server-side changes
+
+Two boot behaviors changed to make this flow work.
+
+1. **Superuser promotion from config is gone.** The server no longer reads `app.admin.users`.
+ The only config-driven account is the bootstrap service account, which the server guards.
+2. **Boot no longer updates existing roles.** It creates a predefined role only when it is
+ missing, and never touches an existing one. Updating definitions moves to reconcile: after an
+ upgrade, the first run plans the new defaults, a human reads the plan, and applying it
+ converges.
+
+### Deprecating the boot-time loader
+
+Keeping `resources_config_path` would give the same objects two owners, so it goes. It is
+deprecated, and removed two minor versions later. Migration is one export of permissions and
+roles, committed as the desired-state files, then dropping the setting from the server config.
+
+## Key decisions
+
+1. **Object or value, not a menu.** A resource is one or the other, and that alone fixes what a
+ missing entry means and whether delete applies. Earlier drafts let each kind pick from three
+ behaviors, which was a matrix operators had to memorize.
+2. **One field rule.** Omitted means default, empty means empty, written means the whole value.
+ The same for every field of every kind. This is what lets the export round trip be
+ guaranteed, and it removed a set of per-field special cases the Role kind used to carry.
+3. **Convergence, not transactions.** The API cannot group changes into one atomic unit, and
+ building that would be a lot of machinery. The file is declarative and applying it is safe to
+ repeat, so re-running is the one recovery path for every failure.
+4. **Reads and writes use the public API.** The reconciler is an API client like any other, so
+ every change is validated, audited, and seen by all pods at once.
+5. **Seed files from exports.** Reconciling an export plans no changes, so a kind's first run is
+ safe by construction, and an export captures drift a hand-written file would miss.
+
+## Trade-offs
+
+- A predefined role's definition updates arrive on the next reconcile run after an upgrade, not
+ at boot. Until someone runs it, the old definitions stay.
+- The reset target is compiled into the CLI, so the reconcile image must match the server
+ release. Serving definitions from the server would remove this (future work).
+- A run can stop between adds and deletes, with no rollback. Adds run first so the failure stays
+ on the safe side, and re-running converges.
+- The reconciler trusts the file. The dry-run plan is the safety net; the commands do not ask
+ for confirmation.
+
+## Future work
+
+- A read-only API that returns the server's own predefined-role definitions, so the reset target
+ comes from the running server instead of the CLI's compiled copy. This removes the
+ image-version coupling.
+- Metaschemas as a kind, once the server stops caching them per pod at boot.
+- Passing the auth token without putting it in the process arguments.
+- Billing plans as a kind, replacing the boot-time plans loader.
+- Removing relation-based ownership from the base schema, so narrowing a predefined role
+ restricts everyone who holds it.
+
+## References
+
+- The `reconcile` package and the `reconcile` and `export` commands in this repository.
+- Pixxel rollout: `docs/RFC/001-frontier-gitops-reconcile.md` in pixxelhq/IAM.