YAML Provisioning (Config-as-Code)
Declare enterprises, agents, webhooks, and schemas in version-controlled YAML. AGLedger reconciles them at startup.
When to use this
Use YAML provisioning when you want infrastructure-as-code for your AGLedger deployment:
- Production deployments with known enterprise/agent topology
- CI/CD pipelines that recreate environments from config
- GitOps workflows where config changes go through pull requests
- Multi-environment setups (dev/staging/prod) with environment-specific values
For one-off setup or exploration, the admin API flow is simpler.
How it works
- Create a directory with YAML files organized by entity type
- Set
PROVISIONING_CONFIG_PATHto that directory - AGLedger reconciles the YAML at startup — creating or updating resources
- Trigger hot-reload anytime via
POST /v1/admin/provisioning/reload
Reconciliation is idempotent: running it twice with the same YAML produces the same result. Safe to call from init containers, startup scripts, or CI/CD.
Directory structure
provisioning/
enterprises/
acme-corp.yaml
agents/
procurement-agents.yaml
webhooks/
event-subscriptions.yaml
schemas/
custom-types.yaml
mandate-schema.json # referenced from YAML
receipt-schema.json
Each subdirectory is optional — only include what you need. Files are processed alphabetically within each subdirectory. Both .yaml and .yml extensions are accepted.
YAML schema
Enterprises
# provisioning/enterprises/acme-corp.yaml
enterprises:
- name: Acme Corp
slug: acme-corp # required, 1-63 chars, lowercase + hyphens
email: admin@acme.com # optional
trustLevel: active # sandbox | active | verified (default: sandbox)
config: # optional — enterprise enforcement config
enforcement:
toleranceEnforcement: enforced
deadlineEnforcement: advisory
schemaValidation: advisory
# Inline agents — created and automatically approved for this enterprise
agents:
- slug: procurement-bot
displayName: Procurement Orchestrator
trustLevel: active
agentClass: system # personal | system | team | ephemeral
apiKeys:
- label: primary
scopeProfile: agent-full # named profile (see Scope Profiles below)
# Enterprise-level API keys
apiKeys:
- label: dashboard
scopeProfile: dashboard
- label: ci-pipeline
scopeProfile: iac-pipeline
Standalone agents
For agents not tied to a specific enterprise at provisioning time:
# provisioning/agents/shared-agents.yaml
agents:
- slug: analytics-agent
displayName: Market Analytics Bot
email: analytics@acme.com # optional
trustLevel: active
agentClass: system
agentCardUrl: https://acme.com/.well-known/agent.json # optional, A2A discovery
apiKeys:
- label: primary
scopeProfile: agent-full
Webhooks
# provisioning/webhooks/event-subscriptions.yaml
webhooks:
- ownerSlug: acme-corp # must match an enterprise or agent slug
ownerType: enterprise # enterprise | agent (default: enterprise)
url: https://acme.com/webhooks/agledger # HTTPS required
eventTypes: # optional — omit to receive all events
- mandate.fulfilled
- mandate.failed
- receipt.submitted
format: standard # standard | cloudevents (default: standard)
Custom schema types
# provisioning/schemas/custom-types.yaml
schemas:
- contractType: ACME-AUDIT-v1 # cannot start with ACH- (reserved)
displayName: Internal Audit
category: compliance
mandateSchema: # inline JSON Schema
type: object
required: [audit_scope, deadline]
properties:
audit_scope: { type: string }
deadline: { type: string, format: date-time }
receiptSchema: receipt-schema.json # or file path (relative to provisioning dir root)
rulesConfig: # optional
syncRuleIds: []
asyncRuleIds: []
Schema file paths are relative to the provisioning directory root. Absolute paths and .. traversal are blocked for security.
Environment variable substitution
Use ${VAR} or ${VAR:-default} in any YAML string value:
enterprises:
- name: ${ENTERPRISE_NAME}
slug: ${ENTERPRISE_SLUG:-my-corp}
email: ${ADMIN_EMAIL:-noreply@example.com}
agents:
- slug: ${AGENT_SLUG:-default-agent}
displayName: ${AGENT_NAME}
Rules:
- Variable names must be uppercase with underscores:
[A-Z_][A-Z0-9_]* - Substitution happens after YAML parsing — special characters in env var values can't inject YAML structure
- Missing variables without defaults cause an error (the resource is skipped, others continue)
- Denylist:
DATABASE_URL,API_KEY_SECRET,VAULT_SIGNING_KEY,WEBHOOK_ENCRYPTION_KEY,AGLEDGER_LICENSE_KEY, and other secret-bearing variables are blocked from substitution
Scope profiles
API keys can use a named scope profile instead of listing individual scopes:
| Profile | Role | Scopes |
|---------|------|--------|
| standard | enterprise | Core lifecycle: mandates, receipts, schemas, webhooks |
| dashboard | enterprise | Read-only: mandates:read, schemas:read, audit:read |
| iac-pipeline | enterprise | Config management: mandates:write, schemas:write, webhooks:write |
| agent-full | agent | All agent scopes including disputes, compliance, agents:read |
| agent-readonly | agent | Read-only agent access |
| sidecar | agent | Minimal scopes for governance sidecar proxy |
| schema-manager | enterprise | Schema registry management |
Use scopeProfile OR scopes (not both):
apiKeys:
- label: primary
scopeProfile: agent-full # named profile
- label: limited
scopes: # explicit scope list
- mandates:read
- mandates:write
- receipts:write
Configuration
Three environment variables control provisioning behavior:
| Variable | Default | Description |
|----------|---------|-------------|
| PROVISIONING_CONFIG_PATH | (empty — disabled) | Path to the provisioning directory. Empty = provisioning disabled. |
| PROVISIONING_DRY_RUN | false | When true, validate YAML and report what would change without writing to the database. |
| PROVISIONING_PRUNE | false | When true, resources in the database that are not in the YAML are deactivated. |
Kubernetes / Helm
The Helm chart (v0.15.7+) has built-in provisioning support. Two approaches:
Option A: Inline YAML in Helm values (simplest)
# values.yaml
provisioning:
enabled: true
enterprises:
acme-corp.yaml: |
enterprises:
- name: Acme Corp
slug: acme-corp
trustLevel: active
agents:
- slug: procurement-bot
displayName: Procurement Orchestrator
trustLevel: active
apiKeys:
- label: primary
scopeProfile: agent-full
agents: {}
webhooks:
notifications.yaml: |
webhooks:
- ownerSlug: acme-corp
url: https://hooks.example.com/agledger
schemas: {}
The chart creates a ConfigMap automatically and mounts it at /etc/agledger/provisioning.
Option B: Reference existing ConfigMaps
# values.yaml
provisioning:
enabled: true
existingConfigMaps:
enterprises: my-enterprise-config
agents: my-agent-config
webhooks: "" # empty = skip this subdirectory
schemas: ""
Each ConfigMap is mounted as the corresponding subdirectory. Use this when provisioning config is managed separately (e.g., by a different team or GitOps pipeline).
Additional Helm options:
provisioning:
enabled: true
configPath: /etc/agledger/provisioning # mount path (default)
dryRun: false # validate without writing
prune: false # deactivate orphaned resources
immutable: false # immutable ConfigMap (reduces apiserver load)
Hot reload
Trigger reconciliation without restarting:
curl -X POST "$API_BASE/v1/admin/provisioning/reload" \
-H "Authorization: Bearer $PLATFORM_KEY"
Auth: Platform key required (ach_pla_*). Agent and enterprise keys are rejected with 403.
Rate limit: 5 requests per minute.
Response:
{
"enterprises": {
"created": 1, "updated": 0, "pruned": 0,
"createdSlugs": ["acme-corp"], "updatedSlugs": [], "prunedSlugs": []
},
"agents": {
"created": 2, "updated": 0, "pruned": 0,
"createdSlugs": ["procurement-bot", "analytics-agent"],
"updatedSlugs": [], "prunedSlugs": []
},
"webhooks": { "created": 1, "updated": 0, "pruned": 0,
"createdSlugs": [], "updatedSlugs": [], "prunedSlugs": [] },
"schemas": { "created": 0, "updated": 0, "pruned": 0,
"createdSlugs": [], "updatedSlugs": [], "prunedSlugs": [] },
"apiKeys": {
"created": 3, "skipped": 0,
"generated": [
{ "ownerSlug": "acme-corp", "ownerType": "enterprise",
"label": "dashboard", "apiKey": "ach_ent_abc123..." },
{ "ownerSlug": "procurement-bot", "ownerType": "agent",
"label": "primary", "apiKey": "ach_age_def456..." }
]
},
"errors": [],
"dryRun": false
}
Provisioning status
Check configuration and managed resource counts without triggering a reload:
curl "$API_BASE/v1/admin/provisioning/status" \
-H "Authorization: Bearer $PLATFORM_KEY"
{
"configured": true,
"configPath": "/etc/agledger/provisioning",
"dryRun": false,
"prune": false,
"lastReloadAt": "2026-04-08T15:30:00.000Z",
"managed": {
"enterprises": 3,
"agents": 7,
"webhooks": 2,
"schemas": 1
}
}
Managed resources
Resources created by YAML provisioning are tagged with managed_by = 'provisioning' in the database. This has two effects:
-
YAML wins. When you reload, provisioned resources are updated to match the YAML. Manual API changes to managed resources are overwritten on next reload.
-
No collisions. If a resource with the same slug was created manually (via the admin API), provisioning will not overwrite it. Instead, you'll see an error:
{ "resource": "enterprise", "slug": "acme-corp", "error": "Slug in use by non-managed resource" }
If you want to bring a manually-created resource under YAML management, delete it via the admin API first, then add it to your YAML.
Pruning
By default, removing an entity from your YAML does not delete it from the database — it just stops being managed. This is the safe default.
With PROVISIONING_PRUNE=true, orphaned resources (in the database with managed_by = 'provisioning' but not in the current YAML) are deactivated:
- Enterprises and agents:
managed_bycleared, all their API keys deactivated - Webhooks: Deactivated (
is_active = false),managed_bycleared - Schemas:
managed_bycleared (schema definitions preserved)
Pruning never deletes data — it deactivates. Re-add the entity to your YAML and reload to reactivate.
Recommendation: Run with PROVISIONING_DRY_RUN=true first to preview prune effects before enabling.
API key handling
API keys provisioned via YAML follow special rules:
- Generated once, returned in response. The plaintext key is included in the reload response's
apiKeys.generatedarray. Subsequent reloads skip existing keys (matched by owner + label). - No updates. Changing the scope profile on an existing key in YAML does nothing — the key was already created. To change scopes, use
PATCH /v1/admin/api-keys/:keyIdor delete and re-create. - Labels are unique per owner. Two keys with the same label on the same owner: the second is skipped.
Important: Capture the plaintext keys from the reload response on first run. They are not retrievable afterward.
Concurrency
A PostgreSQL advisory lock prevents concurrent reconciliations. If two pods or two reload calls race:
- One acquires the lock and proceeds
- The other returns immediately with an error:
"Another provisioning reconciliation is in progress"
This is safe — retry or let the next startup handle it.
Dry-run mode
Validate your YAML without making changes:
PROVISIONING_DRY_RUN=true
The response shows what would be created/updated, with "dryRun": true.
Error handling
Provisioning is non-fatal — the server starts even if provisioning fails. Individual entity failures don't block other entities:
{
"enterprises": { "created": 1, "updated": 0, "pruned": 0 },
"agents": { "created": 0, "updated": 0, "pruned": 0 },
"errors": [
{ "resource": "agent", "slug": "bad-agent",
"error": "Slug in use by non-managed resource" }
]
}
Common errors:
| Error | Cause | Fix |
|-------|-------|-----|
| Slug in use by non-managed resource | Resource with this slug was created via admin API | Delete the manual resource first, or change the slug |
| Owner "xyz" (enterprise) not found | Webhook references a slug that doesn't exist | Check ownerSlug matches an enterprise or agent slug |
| Environment variable X is not set | ${VAR} used but env var not set | Set the env var or add a default: ${VAR:-fallback} |
| Contract type cannot start with ACH- | Custom schema uses reserved prefix | Use a different prefix (e.g., ACME-AUDIT-v1) |
File limits
| Limit | Value |
|-------|-------|
| Max file size | 1 MB per YAML file |
| YAML alias count | 10 (prevents denial-of-service via alias expansion) |
| Schema file paths | Must be relative, no .. traversal |
Complete example
A production-ready provisioning directory for a logistics company:
# provisioning/enterprises/logistics-corp.yaml
enterprises:
- name: ${COMPANY_NAME:-Global Logistics Corp}
slug: ${COMPANY_SLUG:-global-logistics}
email: ${ADMIN_EMAIL}
trustLevel: active
config:
enforcement:
toleranceEnforcement: enforced
deadlineEnforcement: enforced
schemaValidation: advisory
agents:
- slug: dispatch-orchestrator
displayName: Dispatch Orchestrator
trustLevel: active
agentClass: system
apiKeys:
- label: primary
scopeProfile: agent-full
- slug: compliance-auditor
displayName: DOT Compliance Auditor
trustLevel: active
agentClass: system
apiKeys:
- label: primary
scopeProfile: agent-full
apiKeys:
- label: dashboard
scopeProfile: dashboard
- label: ci
scopeProfile: iac-pipeline
# provisioning/agents/fleet-agents.yaml
agents:
- slug: route-optimizer
displayName: Route Optimization Agent
trustLevel: active
agentClass: system
apiKeys:
- label: primary
scopeProfile: agent-full
# provisioning/webhooks/monitoring.yaml
webhooks:
- ownerSlug: global-logistics
url: ${WEBHOOK_URL:-https://monitoring.internal/agledger}
eventTypes:
- mandate.fulfilled
- mandate.failed
- mandate.expired
# provisioning/schemas/logistics-types.yaml
schemas:
- contractType: LOGISTICS-DOT-v1
displayName: DOT Compliance Check
category: compliance
mandateSchema: dot-compliance-mandate.json
receiptSchema: dot-compliance-receipt.json
Validated end-to-end: 42 unit tests + 12 integration tests. View test source.