Provision a Server from config-as-code

A Server's setup (its organization, agents, the API keys they authenticate with, webhook subscriptions, and custom contract types) can be declared in a directory of YAML files and reconciled on every boot. This is the repeatable, reviewable, GitOps-friendly alternative to clicking through admin API calls by hand, and it is what makes AGLedger installable by an agent or an automation pipeline rather than a human at a terminal: point the Server at a directory, and the identities and types it needs come up with it.

Provisioning is additive to the admin API, not a replacement. Agents, API keys, contract types, and webhook subscriptions can also be created with POST /v1/admin/agents, POST /v1/admin/api-keys, and the schema and webhook endpoints; provisioning makes the whole set declarative and idempotent. Organizations are the one exception. A Server runs a single org, created at first boot, and provisioning manages that org rather than creating others: a production Server has no create-org call, and GET /v1/admin/orgs lists the one there is. Provisioned resources are tagged managed_by = provisioning; resources you create through the API directly are not touched by reconciliation.

The directory

Set PROVISIONING_CONFIG_PATH to a directory and the Server reads it at startup. The layout groups resources by kind:

provisioning/
  orgs/       # orgs, with inline agents and API keys
  agents/     # standalone agents (reference their org by name)
  webhooks/   # webhook subscriptions (reference orgs/agents by name)
  schemas/    # custom contract types (inline, or referencing a JSON file)

Those four are the only directories this loader reads, alongside trusted-issuers.yaml, which has a loader of its own and can sit at the provisioning root or in a trusted-issuers/ subdirectory beside them. YAML anywhere else is logged with a WARN at load naming the files it holds, whether it sits in a directory nothing reads or loose at the root (which is where a hand-rolled Kubernetes ConfigMap puts it, since a ConfigMap key cannot contain a slash). The alternative is an install that comes up with none of those resources, no error, and no mention of the directory anywhere, which is how a retired name costs an afternoon.

A starter set ships in the agledger-ai/install repository under examples/provisioning/. Copy it, edit it, and point the Server at it:

$ cp -r examples/provisioning /etc/agledger/provisioning
$ export PROVISIONING_CONFIG_PATH=/etc/agledger/provisioning

Check two things before the copy is pointed at a Server.

The org name matches the Server's org. The starter files declare the org as Default, and agents/ and webhooks/ reference it by that name. A Server holds one org, and an install made with install.sh already has it, named Default (from AGLEDGER_DEFAULT_ORG_NAME) unless you changed that. If you did, read the name:

$ curl -s -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/orgs"

and replace Default with it in orgs/example.yaml (name), agents/example.yaml (orgName) and webhooks/example.yaml (ownerName and ownerOrgName). The next reconcile takes the existing org over with its config, inline agents and keys. A YAML org under any other name is refused, and every agent and webhook that references it fails with it:

This Server runs a single org, and "Acme Corp" would be a second one, so it was not created. The
Server's org is "Default" (<org id>). Name this YAML org "Default" to manage it from provisioning.

The one case where the YAML name wins is a Server whose first boot already has PROVISIONING_CONFIG_PATH set: when orgs/ declares exactly one org, first boot creates the org under that name in place of AGLEDGER_DEFAULT_ORG_NAME. An org is not renamed from YAML after that.

trusted-issuers.yaml names your IdP. The starter file declares two trust anchors for https://idp.example.com/ (one for operators, one for delegating principals) with enabled: false, so a verbatim copy trusts no issuer. Put your own IdP's values in it and set enabled: true on the entries you use, or delete the file.

On Docker Compose, set PROVISIONING_CONFIG_PATH in compose/.env and mount the directory into the agledger-api and agledger-worker services in compose/docker-compose.override.yml (the installer creates it empty, and COMPOSE_FILE in .env names it), then docker compose up -d:

services:
  agledger-api:
    volumes: ["/etc/agledger/provisioning:/etc/agledger/provisioning:ro"]
  agledger-worker:
    volumes: ["/etc/agledger/provisioning:/etc/agledger/provisioning:ro"]

On Kubernetes, declare the content under provisioning.* in the Helm chart's values and it renders and mounts the ConfigMap for you. Do not hand-roll the ConfigMap.

trusted-issuers.yaml is a single file rather than a directory of them, so on Kubernetes it has its own two values:

provisioning:
  enabled: true
  trustedIssuers:
    - issuerUrl: https://login.example.com/
      expectedAudience: agledger
      appliesTo: any
      label: Corporate IdP

The chart renders those entries under the file's trusted_issuers: key and projects them as a directory at <configPath>/trusted-issuers/, which is the second location the loader accepts. A ConfigMap mounted into a single file through subPath is never refreshed by kubelet, so an operator editing it and calling the reload endpoint would read the original bytes for the life of the pod; a directory mount is updated in place, and kubectl apply followed by a reload applies the change without restarting the pods. Point provisioning.existingConfigMaps.trustedIssuers at a ConfigMap you manage instead; its key has to be named exactly trusted-issuers.yaml, since the file is projected by name. With neither set, registering an IdP on Kubernetes means calling the admin API after every install.

Each entry takes the same keys as the create body of POST /v1/admin/trusted-issuers, validated by the same grammar, so a value the admin API refuses fails here too: issuerUrl, expectedAudience, appliesTo (agent, admin, principal, or any, the default), orgId, jwksUri, expectedAzp, claimMapping, allowedAlgs, maxCredentialTtlSeconds, autoProvisionAgents, autoProvisionScopeProfile, autoProvisionMaxAgents, subjectAllowlist, jtiSingleUse, label and enabled. A key this loader does not read is refused rather than ignored, because the defaults that would apply in an ignored key's place are the permissive ones.

Two of those bound how a token may be presented, and both are worth setting from config rather than by hand. subjectAllowlist names the exact sub values the row admits, and with autoProvisionAgents on it is what stops an IdP-side assignment mistake from creating an agent here. jtiSingleUse makes each admin bearer good for one request across every replica, keyed on the token's jti claim or, when the token carries none, on the claim the row names under the claimMapping logical name jti. Entra ID mints uti rather than jti, so an Entra row needs that mapping for the flag to enforce anything, and the same mapping also makes the cert exchange single use per uti, since that door reads the same resolved id and enforces one exchange per id whatever jtiSingleUse says. The authentication guide covers which IdPs mint an id and what each key refuses.

The loader matches a file entry to a row by issuerUrl, expectedAudience, appliesTo and orgId. Editing expectedAudience or appliesTo on an existing entry, or binding a global entry to an org, updates that row in place: its id, its issued certs and its token-id register survive, and the TRUSTED_ISSUER_CHANGED chain entry carries the previous values. Moving a row from one org to another, or widening an org-bound row to every org, is never an in-place edit: it retires the old row and inserts a new one, and a retired row that has issued certs is kept and reported rather than deleted. When two retired rows could be the one an entry edits, or two entries could be editing one row, the entries involved are refused, naming every row, and nothing is changed.

Each entry succeeds or fails alone. A new entry whose OIDC discovery fails, whose jwksUri the egress guard refuses, or whose orgId names no org is refused by index, and the rest of the file applies. An entry that already has a row keeps its stored jwksUri when discovery fails, applies every other field it declares, and is reported in warnings[], so setting enabled: false on the entry for an IdP that is down still disables it. An unchanged entry with no explicit jwksUri re-runs discovery on every reload and picks up an IdP's moved jwks_uri. The warnings from a boot or SIGHUP pass appear on the status endpoint as trustedIssuersWarnings. When a reload reports no errors and changed nothing, trustedIssuersUnchangedSinceLastLoad: true on the status endpoint says the pass read the same bytes as before, which means an edit you expected never reached the pod.

Declaring resources

An org can carry its agents and their keys inline, so one file stands up a complete working identity. The natural key is the resource name. Keep it stable across reconciles, because renaming creates a new resource.

orgs/example.yaml, with the starter file's supplied-key entries uncommented:

orgs:
  - name: Default
    # Supply the key material yourself and the reconciler stores only its
    # hash. The key is in your secret store before the Server boots, so there
    # is nothing to capture out of a response. The starter file ships these
    # commented out, because a bare ${VAR} with no default is a parse error
    # when unset. Each entry needs its own value: api_keys.key_hash is UNIQUE.
    apiKeys:
      - role: admin
        label: gitops-admin-key
        scopeProfile: admin-iac
        apiKey: ${ACME_INTEGRATION_KEY}
    # Agents owned by this org (they inherit org_id from the parent).
    agents:
      - displayName: Acme Task Processor
        agentClass: system
        apiKeys:
          - role: agent
            label: processor-key
            scopeProfile: agent-full
            apiKey: ${ACME_PROCESSOR_KEY}

A key declared under an org is that org's admin key, and one declared under an agent is that agent's key, so role can be left out. Naming the other role (agent under an org, admin under an agent) is the pair POST /v1/admin/api-keys refuses with a 400, and it fails that entry at load: for a key under an org, the whole org entry with its inline agents; for a key under a top-level agent, that agent. Prune is suppressed for the run.

Custom contract types are declared the same way: inline, or referencing a JSON Schema file relative to the provisioning root (schemas/). A schema entry uses the same top-level placement as POST /v1/schemas for the keys it supports: type and recordSchema, plus optional completionSchema, displayName, description, category, fieldMappings, defaultGateMode and compatibilityMode. Gate rules go in fieldMappings at the top level, exactly as in a register body. Register fields outside that list are not provisioning-configurable; declaring them, or any other unknown key, fails the entry at load time with a per-type error. Rule wiring gets the same validation as the register API (malformed mapping elements, duplicate ruleIds, unknown verbs, and non-resolving criteria/evidence paths are all load errors), so misplaced or broken gate config can never silently provision a Type that enforces nothing. A negative maxTolerance, or more fieldMappings than the register API accepts on any type, fails the entry. An entry with more fieldMappings than POST /v1/schemas allows on a type with no org, but within what it accepts on any type, still loads: it is reported under loadWarnings, naming the entry and the cap the API would apply, and a warning does not suppress prune.

compatibilityMode takes the same values as POST /v1/schemas and is part of the manifest digest, so a body registered through either door with the same declared value carries one digest. Omitted, it is backward, the API's default, so the two doors also agree when the key is left out.

A schema entry whose type your org has already registered through POST /v1/schemas is refused with a per-type error naming that org, and nothing is written: the org's records resolve to its own registration first, so a provisioned type of the same name would not take effect there. Rename the entry. Disabling and deleting the org's registration also clears it, but only while no record names that type: a disabled registration still shadows the name, and one with records cannot be deleted. The API refuses the converse, an org registration of a provisioned type's name.

The schema bodies themselves are validated at load too, by the same meta-schema walk and Ajv trial compile that POST /v1/schemas runs. A recordSchema or completionSchema must be a JSON object with type: object and a non-empty root required array; $id, $data, $code, $async, prefixItems and contentSchema are refused; format must be one of the allowed values; regexes are checked for catastrophic backtracking; the body is bounded at depth 5, 200 nodes and 50 KB; and it has to compile under Ajv, so type: strng fails at load rather than on the first record written against the type. GET /v1/schemas/meta-schema serves the authoritative constraints, including the allowed formats and the applicator keywords. Compatibility against the versions already registered needs a database round trip, so it runs at reconcile rather than at load: an entry that changes anything the manifest digest covers is registered as the next version, as POST /v1/schemas registers it, and is held to the same check under the current version's compatibilityMode. The version existing records and completions were validated against stays as it was, and the new version takes its status, so an edit does not re-enable a type someone disabled. An edit that mode refuses is reported in errors[] and nothing is written. To make it, reload once with the schemas unchanged and compatibilityMode: none (which registers the next version under that mode), then reload with the change, or declare the new shape under a new type name.

Two spellings are easy to write in YAML and both fail:

schemas:
  - type: DOCS-NULL-COMPLETION-v1
    recordSchema:
      type: object
      required: [summary]
      properties:
        summary: { type: string }
    completionSchema:            # YAML null, not an empty object: refused
  - type: DOCS-EMPTY-OBJECT-v1
    recordSchema:
      type: object
      additionalProperties: false   # no root `required`: refused
<file>: schemas(DOCS-NULL-COMPLETION-v1).completionSchema: must not be null. Omit the key entirely, or write
`completionSchema: {}`, for a notarize-only type (terminal at RECORDED on create): POST /v1/schemas
takes both and stores `{}`. Otherwise give it a JSON Schema object or a file path.
<file>: schemas(DOCS-EMPTY-OBJECT-v1).recordSchema: Schema must have at least one required property

Omit completionSchema, or write completionSchema: {}, for a notarize-only type; both are what POST /v1/schemas accepts and both store {}. A bare key is null and is not the same thing.

Webhook subscriptions reference their owner by name (ownerType: org matches an org name; ownerType: agent matches an agent's displayName, with ownerOrgName to disambiguate) and take the same fields as POST /v1/webhooks: url, eventTypes, format, signingAlg, and recordTypes to scope the subscription to specific contract types (fail-closed, server-side; see the webhooks guide). YAML also takes secret, the HMAC shared secret, which belongs in a ${VAR} reference rather than in the file. Omit recordTypes, or write recordTypes: ['*'], to receive every type. Omitting eventTypes also means every event, as ['*'] does, although POST /v1/webhooks requires the key. A declared webhook is held to the same rules as one created through the API: the owner org's maxWebhookSubscriptions, the same recordTypes bounds, and the same egress check, so a hostname that resolves to a private address is refused unless SSRF_ALLOW_CIDRS admits it, while a lookup that fails or times out refuses nothing. See the example files for the full shape of each kind.

Reconciliation on boot

The Server reconciles the directory every time it starts. Each resource is created if absent and updated to match the file if present; nothing is deleted unless you opt into pruning (below). The startup log line Provisioning complete reports what was applied. Here it is for the starter set with the two supplied keys above, on an install whose org is Default (name lists abridged):

{
  "orgs":     { "created": 0, "updated": 1, "pruned": 0, "updatedNames": ["Default"], ... },
  "agents":   { "created": 2, "updated": 0, "pruned": 0,
                "createdNames": ["Acme Task Processor", "External Monitor Agent"], ... },
  "webhooks": { "created": 4, "updated": 0, "pruned": 0,
                "createdNames": ["Default:https://hooks.acme-corp.example.com/agledger", ...], ... },
  "schemas":  { "created": 3, "updated": 0, "pruned": 0,
                "createdNames": ["CUSTOM-DELIVERY-NOTE-v1", "CUSTOM-INVOICE-v1", "CUSTOM-REPORT-v1"], ... },
  "apiKeys": {
    "created": 2, "skipped": 0,
    "keys": [
      { "ownerName": "Acme Task Processor", "ownerType": "agent", "label": "processor-key",
        "keyId": "01a0cd38-6a7d-708e-b9f3-ae86961b697b", "source": "supplied" },
      { "ownerName": "Default", "ownerType": "org", "label": "gitops-admin-key",
        "keyId": "01a0cd38-6a7f-7059-afd1-0bf79f568622", "source": "supplied" }
    ]
  },
  "errorCount": 0, "dryRun": false
}

The org counts as updated, not created: the Server's org already existed, and the reconcile took it over. A webhook is named by its owner and URL.

Every key entry carries keyId and source. source: supplied means you provided the material and the Server stored only its hash. source: generated means the Server minted it, and the log never carries the plaintext: a key the Server mints is a credential whose home is your secret store, not your log pipeline. The one place a minted key's plaintext appears is the apiKeys.generated[] array of the POST /v1/admin/provisioning/reload response (below), once.

Provisioning also runs at boot, and at boot nobody called that route, so a key minted by first boot has no readout anywhere and the only recovery is to deactivate it with a platform key (PATCH /v1/admin/api-keys/{keyId}) and reload. To have the Server mint a key, add the entry to a Server that is already running and call the reload route yourself. To make config the whole story, supply the material.

Schema registrations are notarized

Creating a contract type from this directory appends a signed SCHEMA_REGISTERED entry to the platform schema chain, the same entry a POST /v1/schemas registration produces. It carries the type, version, publisher and manifest digest, so the registration is tamper-evident and an offline verifier can attest it alongside everything else in the vault.

The entry also carries source: "provisioning" in its signed payload. Config-as-code has no authenticated caller, so the chain says the file was the authority rather than letting the entry read as an API registration. Both are equally authentic; they are not equally attributable. The attribution sits inside the signature, not in a mutable column beside it, so it is worth the same as the rest of the entry.

One entry is appended when the type is first created, and another for each version a reload registers by changing anything the manifest digest covers (the schema bodies, displayName, description, category, fieldMappings, compatibilityMode). The matching schema.registered row in system_audit_log names the digest it replaced. A change to defaultGateMode alone writes no chain entry, since the digest does not cover it, and writes a schema.default_gate_mode_changed row instead; any change to the stored compatibility mode also writes schema.compatibility_changed. A reload that changes nothing writes nothing.

Status and hot reload

Confirm what is under management with the status endpoint (platform key):

$ curl -s -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/provisioning/status"
{"configured":true,"configPath":"/etc/agledger/provisioning","dryRun":false,"prune":false,
 "lastReloadAt":"2026-09-23T07:43:42.315Z",
 "managed":{"orgs":1,"agents":2,"webhooks":4,"schemas":3,"trustedIssuers":2},
 "loadErrors":[],"loadWarnings":[],"pruneSuppressed":false,
 "trustedIssuersWarnings":[],"trustedIssuersUnchangedSinceLastLoad":false}

Edit the YAML and apply the change without a restart: send SIGHUP, or call the reload endpoint:

$ curl -s -X POST -H "Authorization: Bearer $PLATFORM_KEY" "$U/v1/admin/provisioning/reload"

Run against the same unchanged directory (name lists and nextSteps descriptions abridged):

{
  "orgs":     { "created": 0, "updated": 1, "pruned": 0, "updatedNames": ["Default"], ... },
  "agents":   { "created": 0, "updated": 2, "pruned": 0, ... },
  "webhooks": { "created": 0, "updated": 0, "pruned": 0, ... },
  "schemas":  { "created": 0, "updated": 0, "pruned": 0, ... },
  "apiKeys":  { "created": 0, "skipped": 2, "generated": [] },
  "errors": [],
  "trustedIssuers": { "configured": true, "total": 2, "inserted": 0, "updated": 0, "deleted": 0,
                      "errors": [], "warnings": [], "unchangedSinceLastLoad": true, ... },
  "loadWarnings": [],
  "dryRun": false,
  "nextSteps": [
    { "action": "Inspect provisioning status", "method": "GET", "href": "/v1/admin/provisioning/status", ... },
    { "action": "Capture raw API keys from THIS response", "method": "GET", "href": "/v1/admin/api-keys", ... }
  ]
}

Reload is idempotent: unchanged orgs and agents count as updated, unchanged webhooks and schemas count nowhere, existing keys count as skipped, and only genuinely new keys appear in apiKeys.generated[]. An org entry with no config: block leaves the stored org config as it is, including caps set through PATCH /v1/admin/orgs/{id}/config; an entry that declares one replaces it, and writes the chain entry and admin.org_config_updated row only when the stored value actually changes.

A declared key carries an expiry like any other: API_KEY_DEFAULT_LIFETIME_SECONDS gives it 90 days unless the install says otherwise. Each reconcile pushes that window forward on the keys it manages, so a Server that boots, takes a SIGHUP or answers POST /v1/admin/provisioning/reload inside the window keeps them alive with the same material. A Server that does none of those things for a full lifetime lets the key lapse, and the next reconcile revives it, so reconcile at least as often as the lifetime or set API_KEY_DEFAULT_LIFETIME_SECONDS=0. Keys minted before the install had a default are left with no expiry, and the reconciler never adds one to them. A key that declares its own expiresAt is the exception: that instant is capped the way the API caps it, never pushed forward, and refused when it has already passed; once a live key's declared expiry passes, each reload reports the key rather than calling it healthy. allowedIps is accepted with the API's validation. A declared agent in an org deactivated through the API is refused, so no key is minted that would fail at auth.

Like every AGLedger mutation, the response carries nextSteps so an agent driving the install knows what to do next without reading docs: here, that any source: generated entry in generated[] must be captured now, because that plaintext is not retrievable later.

Secrets, dry run, and pruning

Where this fits

Provisioning is the natural next step after installing a Server and minting the platform key: instead of creating each org and agent by hand, declare the whole set as code and let the Server reconcile it. The request/response shapes for the underlying admin endpoints (/v1/admin/orgs, /v1/admin/agents, /v1/admin/api-keys, /v1/admin/provisioning/*) are in the API reference. For rotating and reloading config as a recurring operator task, see day-2 operations.