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:
- The ADL plugin bootstraps domain tables before the seeder runs
- The authd plugin registers
auth.register,admin.users.verify,admin.users.set_roles,admin.roles.createbefore the seeder calls them - Domain actions like
adl.{domain}.{resource}.createare 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
| Property | Type | Required | Description |
|---|---|---|---|
version | string | yes | Manifest format version (currently "1.0") |
domain | string | no | Domain context for data section |
roles | array | no | Role definitions |
users | array | no | User accounts |
assignments | array | no | User-to-role assignments |
data | array | no | Domain 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:
| Phase | Idempotency Mechanism |
|---|---|
| Roles | authd returns 409 if role exists; seeder skips |
| Users | authd returns 409 if username/email exists; seeder skips |
| Assignments | set_roles is idempotent — same roles = no-op |
| Data | Depends 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
}
}
| Key | Type | Default | Description |
|---|---|---|---|
seed_file | string | required | Path to seed manifest YAML |
run_on_start | boolean | false | Execute 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
| Feature | Integration |
|---|---|
| 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