Chapter 31 · Volume IV — Runtime Subsystems

Lua Scripting Plugin

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

HookWhenCan Abort?Return Value
beforeCreateBefore INSERTYesnil or {field=value} or false, code, error_code, message
afterCreateAfter INSERTNoIgnored
beforeUpdateBefore UPDATEYesnil or {field=value} or false, code, error_code, message
afterUpdateAfter UPDATENoIgnored
beforeDeleteBefore DELETEYesnil or false, code, error_code, message
afterDeleteAfter DELETENoIgnored

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

ModuleStatusAvailable Functions
math.*✅ FullAll math functions
string.*✅ FullAll string functions
table.*✅ FullAll table functions
os.time(), os.clock(), os.date()✅ AllowedSafe time functions
coroutine.*✅ FullCoroutine support

Blocked Functions

FunctionReason
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

FeatureIntegration
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