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:

For one-off setup or exploration, the admin API flow is simpler.

How it works

  1. Create a directory with YAML files organized by entity type
  2. Set PROVISIONING_CONFIG_PATH to that directory
  3. AGLedger reconciles the YAML at startup — creating or updating resources
  4. 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:

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:

  1. YAML wins. When you reload, provisioned resources are updated to match the YAML. Manual API changes to managed resources are overwritten on next reload.

  2. 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:

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:

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:

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.