31.1 Overview
The Lua scripting plugin provides a sandboxed scripting environment for implementing custom business logic at lifecycle hook points. Hooks are declared in the ADL YAML and implemented in Lua scripts. The plugin exposes platform capabilities through the kalos global module.
Lua scripts run inside a controlled sandbox — standard library functions that access the filesystem, execute shell commands, or perform unrestricted I/O are blocked. All platform interaction goes through the kalos module.
31.2 Hook Declaration
Hooks are declared in the hooks: block on any resource:
resources:
GrantApplication:
object:
# ... fields
hooks:
beforeCreate: [generateApplicationNumber, validateOpportunityIsOpen]
afterCreate: [createDefaultComplianceChecklist]
beforeUpdate: [enforceLockedApplicationRule]
afterUpdate: [recalculateApplicationBudgetTotal]
beforeDelete: [checkDependentReferences]
afterDelete: [notifyRelatedParties]
Hook Lifecycle
Client Request
│
▼
[ADL Action Handler]
│
├─ beforeCreate/Update/Delete hooks ← can abort with error
├─ Field validation
├─ SQL INSERT / UPDATE / DELETE
└─ afterCreate/Update/Delete hooks ← fire-and-forget
Hook Types
| Hook | When | Can Abort? | Return Value |
|---|---|---|---|
beforeCreate | Before INSERT | Yes | nil or {field=value} or false, code, error_code, message |
afterCreate | After INSERT | No | Ignored |
beforeUpdate | Before UPDATE | Yes | nil or {field=value} or false, code, error_code, message |
afterUpdate | After UPDATE | No | Ignored |
beforeDelete | Before DELETE | Yes | nil or false, code, error_code, message |
afterDelete | After DELETE | No | Ignored |
31.3 Hook Implementation
Hooks are implemented in Lua scripts stored in hooks/{resource}/{hookName}.lua:
hooks/
GrantApplication/
generateApplicationNumber.lua
validateOpportunityIsOpen.lua
createDefaultComplianceChecklist.lua
Script Structure
-- hooks/GrantApplication/generateApplicationNumber.lua
local function generate(req)
-- Extract data from request
local opp_id = req.data.fundingOpportunityId
-- Call another action to fetch related data
local opp_resp = kalos.call("adl.heras.funding_opportunities.get", {
id = opp_id
})
if opp_resp.status ~= 200 then
return false, 422, "INVALID_OPPORTUNITY",
"Funding opportunity not found"
end
local opp = opp_resp.data
-- Generate application number
local year = os.date("%Y")
local prefix = opp.agencyCode or "UNK"
local sequence = get_next_sequence(year, prefix)
local app_number = string.format("APP-%s-%s-%04d",
year, prefix, sequence)
kalos.log.info("Generated application number: " .. app_number)
-- Return computed field
return { applicationNumber = app_number }
end
-- Register the hook
kalos.action("generateApplicationNumber", generate)
Hook Return Values
Success (no data):
return nil
-- or simply end the function without returning
Success with computed fields:
return { applicationNumber = "APP-2026-001" }
Abort with error:
return false, 422, "VALIDATION_ERROR", "Application is locked"
The error tuple is (false, http_code, error_code, error_message).
31.4 The kalos Module
All platform functionality is exposed through the global kalos table.
Logging
kalos.log.debug("Computing indirect costs...")
kalos.log.info("Application number generated: APP-2026-0042")
kalos.log.warn("Budget total does not match sum of lines")
kalos.log.error("Failed to lock application: " .. tostring(err))
All log calls route to the structured JSON logger with the [lua] prefix.
JSON Encoding/Decoding
local json_str = kalos.json.encode({ status = "active", count = 5 })
-- {"status":"active","count":5}
local data = kalos.json.decode('{"name":"Alice","age":30}')
-- data.name == "Alice", data.age == 30
Action Registration
kalos.action("myHookName", function(req)
-- hook logic
return { field = value }
end)
Cross-Action Calls
local resp = kalos.call("adl.domain.resource.get", {
id = "record-uuid"
})
if resp.status == 200 then
local record = resp.data
-- use the record
else
kalos.log.error("Failed to fetch record: " .. resp.error_message)
end
The kalos.call API enables hooks to query other resources, create records, and orchestrate cross-resource workflows.
Module Loading
local utils = kalos.require("shared.utils")
local validator = kalos.require("shared.validators")
-- shared/utils.lua and shared/validators.lua loaded from hooks/ directory
31.5 AEL Integration
Lua functions can be called from AEL expressions using the lua: prefix:
computed:
amortization:
type: money
formula: "lua:calculateAmortization(principal, rate, term)"
stored: true
The Lua function is registered in a script loaded at server startup:
-- hooks/shared/financial.lua
local function calculate_amortization(principal, rate, term)
local monthly_rate = rate / 12 / 100
local num_payments = term * 12
local payment = principal * (monthly_rate * (1 + monthly_rate)^num_payments)
/ ((1 + monthly_rate)^num_payments - 1)
return math.floor(payment * 100 + 0.5) -- return cents
end
-- Register for AEL
kalos.function("calculateAmortization", calculate_amortization)
AEL evaluates lua:functionName(args) by calling into the Lua engine and executing the registered function.
31.6 Sandbox Restrictions
The Lua environment is sandboxed to prevent filesystem access, shell execution, and unrestricted I/O.
Allowed Standard Library Functions
| Module | Status | Available Functions |
|---|---|---|
math.* | ✅ Full | All math functions |
string.* | ✅ Full | All string functions |
table.* | ✅ Full | All table functions |
os.time(), os.clock(), os.date() | ✅ Allowed | Safe time functions |
coroutine.* | ✅ Full | Coroutine support |
Blocked Functions
| Function | Reason |
|---|---|
os.execute() | Shell execution |
io.open(), io.read(), io.write() | Filesystem I/O |
require() | Use kalos.require() instead |
loadfile(), dofile() | Arbitrary file loading |
debug.* | Introspection and code manipulation |
31.7 Platform Extensions
The kalos module can be extended from C++ to provide domain-specific utilities.
String Utilities (kalos.str)
kalos.str.slugify("Hello, World!") -- "hello-world"
kalos.str.truncate("Long text...", 10) -- "Long te..."
kalos.str.pad_left("42", 5, "0") -- "00042"
kalos.str.pad_right("Name", 10) -- "Name "
Date Utilities (kalos.date)
kalos.date.now_iso() -- "2026-05-17T14:30:00Z"
kalos.date.add_days("2026-01-01", 30) -- "2026-01-31"
kalos.date.is_before("2026-01-01", "2026-12-31") -- true
kalos.date.days_between("2026-01-01", "2026-01-15") -- 14
UUID Generation (kalos.uuid)
local id = kalos.uuid() -- "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
Read-Only Database Access (kalos.store)
kalos.store(name?) returns a store handle for read-only SQL — only
SELECT/WITH/EXPLAIN are permitted (override with the
scripting.lua.allow_raw_sql config flag). Writes MUST go through kalos.call
to preserve validation, permissions, audit, and history.
local s = kalos.store() -- default store handle; read-only
local rows = s:query(
"SELECT id, title FROM grant_applications WHERE status = ?",
{ "submitted" }
)
for _, row in ipairs(rows) do
kalos.log.info("App " .. row.id .. ": " .. row.title)
end
IMPORTANT: Only read queries are permitted. All write operations must go through kalos.call() to preserve audit trails, validation, and hook chains.
31.8 Custom Types
Lua scripts can define and register custom types accessible from AEL.
Money Type Example
-- Registered in C++ via sol2
local price = Money(1999) -- 1999 cents = $19.99
local tax = Money.from_decimal(5.00) -- $5.00
local total = price + tax
print(total:to_string()) -- "$24.99"
print(total.amount) -- 24.99
Custom types enable domain-specific value objects with validation and behavior encapsulation.
31.9 Error Handling
Returning Errors from Hooks
-- Validation error
return false, 422, "INVALID_AMOUNT", "Budget exceeds funding cap"
-- Permission error
return false, 403, "FORBIDDEN", "Only grants officers can modify submitted applications"
-- Lock error
return false, 423, "LOCKED", "Application is locked after submission"
-- Not found
return false, 404, "NOT_FOUND", "Referenced opportunity does not exist"
Lua Runtime Errors
If a Lua script throws a runtime error (nil dereference, type error), the hook execution fails and the error is logged. The ADL handler returns HTTP 500 with a generic error message (no script details exposed to the client).
31.10 Integration Points
| Feature | Integration |
|---|---|
| Computed Fields (§13) | Lua functions callable from AEL via lua:functionName() |
| State Machines (§12) | Hooks run before/after transitions; can abort based on business logic |
| Validation (§14) | Before hooks add custom cross-field and cross-resource validation |
| History (§21) | After hooks can emit custom history events via kalos.call() |
| Workflows (§25) | Workflow steps can trigger hooks via resource operations |
| Notifications (§23) | After hooks can send notifications via kalos.call() |
| Seeder (§32) | Seeder bypasses hooks via __system__ identity |
End of Chapter 31.
Next: Chapter 32 — Seeder System