Chapter 32 · Volume IV — Runtime Subsystems

Seeder System

32.1 Overview

The seeder plugin populates the database with initial data during server startup. It executes a YAML manifest that declares roles, users, role assignments, and domain data. The seeder is idempotent — running it multiple times produces the same database state.

The seeder loads after all other plugins. By the time it executes, the ADL plugin has bootstrapped all domains and the authd plugin has registered user management actions. The seeder dispatches requests through the message bus using a special __system__ identity that bypasses authentication middleware.


32.2 Plugin Lifecycle

The seeder is a standard Kalos plugin:

on_register()  →  Receive ICore*
on_init()      →  Parse seed file path from config, validate file exists
on_start()     →  Register seeder.run and seeder.status actions
                   If run_on_start is true: execute the seed manifest

Plugin Loading Order

{
  "plugins": [
    { "name": "transport.rest" },
    { "name": "store.sqlite" },
    { "name": "handler.authd" },
    { "name": "adl" },
    { "name": "seeder" }
  ]
}

The seeder is last. This ordering is critical:

  1. The ADL plugin bootstraps domain tables before the seeder runs
  2. The authd plugin registers auth.register, admin.users.verify, admin.users.set_roles, admin.roles.create before the seeder calls them
  3. Domain actions like adl.{domain}.{resource}.create are registered before the seeder dispatches data items

32.3 Seed Manifest Format

The seed manifest is a YAML document with four sections executed in strict order:

version: "1.0"
domain: library

roles:
  - name: admin
    description: "Administrator"
  - name: librarian
    description: "Library staff"

users:
  - username: admin
    email: admin@library.test
    password: "Admin123!"
    verified: true
  - username: librarian1
    email: librarian1@library.test
    password: "Demo123!"
    verified: true

assignments:
  - username: admin
    roles: [admin]
  - username: librarian1
    roles: [librarian]

data:
  - action: adl.library.author.create
    as: admin
    data:
      id: "a0000000-0000-0000-0000-000000000001"
      name: "George Orwell"
      email: "orwell@example.com"

  - action: adl.library.book.create
    as: admin
    data:
      id: "b0000000-0000-0000-0000-000000000001"
      title: "1984"
      isbn: "978-0451524935"
      pages: 328

Manifest Properties

PropertyTypeRequiredDescription
versionstringyesManifest format version (currently "1.0")
domainstringnoDomain context for data section
rolesarraynoRole definitions
usersarraynoUser accounts
assignmentsarraynoUser-to-role assignments
dataarraynoDomain records

32.4 The Four Phases

The seeder executes the manifest in four strict phases. Each phase dispatches requests through the message bus.

Phase 1 — Roles

For each role in roles:, the seeder dispatches:

Action:  admin.roles.create
Data:    { "name": "librarian", "description": "Library staff" }

If the role already exists, authd returns HTTP 409 (conflict). The seeder logs it as “skipped” and continues. This makes seeding idempotent.

Example log output:

Request handled  action=admin.roles.create  code=409  transport=seeder
Seeder: role skipped  name=admin
Request handled  action=admin.roles.create  code=201  transport=seeder
Seeder: role created  name=librarian

The admin role is created by authd internally, so it’s always skipped.

Phase 2 — Users

For each user in users:, the seeder dispatches two actions in sequence:

1. Register:

Action:  auth.register
Data:    { "username": "admin", "email": "admin@library.test",
           "password": "Admin123!" }

2. Verify (if verified: true):

Action:  admin.users.verify
Data:    { "username": "admin" }

The password is hashed via Argon2 inside authd’s registration handler — the seeder never touches password hashes directly. Registration takes ~250-300ms due to intentionally expensive hashing.

Phase 3 — Role Assignments

For each assignment in assignments:, the seeder dispatches:

Action:  admin.users.set_roles
Data:    { "username": "admin", "roles": ["admin"] }

This replaces the user’s current roles with the specified list. The operation is idempotent — calling it twice with the same roles has no effect.

When roles change, authd invalidates the RBAC cache for that user so the next request re-evaluates permissions from the database.

Phase 4 — Data

For each item in data:, the seeder dispatches the specified action with the specified data, impersonating the user named in as::

Action:  adl.library.author.create
As:      admin
Data:    { "id": "a0000000-...", "name": "George Orwell", ... }

The as field specifies which user context to use. The seeder resolves this by looking up the user in the authd store and constructing a Request with the appropriate user_id, username, and roles fields — the same context that would be extracted from a JWT token.


32.5 The __system__ Identity

The seeder uses a special __system__ identity to bypass authentication middleware. This identity is recognized by the kernel’s authentication layer:

if (req.user_context.user_id == "__system__") {
    // Skip token validation
    // Skip permission checks
    next_handler();
}

The __system__ identity is only available to trusted internal components — it cannot be set via HTTP requests.


32.6 User-Provided Primary Keys

The seeder can set explicit primary key values for test data:

data:
  - action: adl.library.book.create
    as: admin
    data:
      id: "b0000000-0000-0000-0000-000000000001"
      title: "1984"

This requires the field to have allowUserProvided: true:

fields:
  id:
    type: uuid
    primaryKey: true
    allowUserProvided: true

Without this flag, the ADL create handler rejects client-supplied UUIDs and auto-generates a random UUID v4 instead. For testing, deterministic IDs are essential so subsequent tests can reference specific records by known IDs.


32.7 State Machine Bypass

Normal HTTP requests cannot create records in arbitrary states — the state field is forcibly set to the initial state. The seeder transport is trusted and can create records in any state:

if (ctx.has_sm && !ctx.sm_field.empty() && !ctx.sm_initial.empty()) {
    if (req.transport != "seeder") {
        create_data[ctx.sm_field] = ctx.sm_initial;
    }
}

This enables bootstrapping test data in specific states (e.g., a ticket in “resolved” state for testing the reopen transition).


32.8 Idempotency

The seeder is idempotent across all four phases:

PhaseIdempotency Mechanism
Rolesauthd returns 409 if role exists; seeder skips
Usersauthd returns 409 if username/email exists; seeder skips
Assignmentsset_roles is idempotent — same roles = no-op
DataDepends on primary key strategy; UUIDs + allowUserProvided: true prevent duplicates

Running the seeder twice produces the same database state. This is safe for development (server restart) and testing (reset to known state).


32.9 Configuration

{
  "seeder": {
    "seed_file": "seeds/seed-library.yaml",
    "run_on_start": true
  }
}
KeyTypeDefaultDescription
seed_filestringrequiredPath to seed manifest YAML
run_on_startbooleanfalseExecute seeder during plugin on_start()

When run_on_start is false, the seeder registers the seeder.run action but does not execute automatically. The action can be triggered via API or internal dispatch.


32.10 Integration Points

FeatureIntegration
Authentication (§29)Seeder creates users and assigns roles via authd actions
Lua Hooks (§31)Seeder bypasses hooks via __system__ identity — hooks do not fire on seed data
State Machines (§12)Seeder can create records in any state (bypasses initial state enforcement)
Migration (§30)Seeder runs after migrations complete and domain is active
Audit (§8)Seed operations are audited with actor: "__system__"
History (§21)History events are NOT emitted for seed operations
Validation (§14)Field validation still applies; invalid seed data returns 422

End of Chapter 32.

Next: Chapter 33 — Data Privacy & GDPR