Contributing an Automation Type
This is the page the Automation Center exists for. The Center schedules, throttles, records and audits; it contains no business rule about anything. Every capability it offers is contributed by the module that owns the records being acted on. This page is the contract for contributing one.
register() call, no
event bus, and no hook to implement; and (2) your handler must never write to a
business table directly — it calls your module's own authoritative service. If
that service does not exist yet in a headless-callable form, extracting it is the
prerequisite, not an afterthought.
What you ship: three files, zero calls
A complete automation type is three files inside your own module, plus one entry in a
config sidecar. You add nothing to modules/Automations/, nothing to
application/, and you call no registration API at boot.
| File | Role | Required |
|---|---|---|
config/automations.php |
The declaration. Names your type key, its owner slug, and where the two classes live. | Required |
automation/<Your>_handler.php |
Candidate selection, rule evaluation, and the call into your module's
mutation service. Implements Automation_handler_interface. |
Required |
automation/<Your>_condition_provider.php |
The allow-list of fields and operators the rule builder may offer.
Implements Automation_condition_provider_interface. |
Optional in principle — required in practice if you want rules |
automation/<Your>_gateway.php |
Only when your mutation service is a controller-shaped trait. See The gateway pattern. | Situational |
The automation/ subdirectory name is a convention, not a rule — the sidecar
names the path. But keep it: the registry enforces that the path stays inside your module
directory, and a predictable location is what makes a type findable by the next reader.
How discovery works
Nothing registers itself. On the request that needs the catalogue,
Automation_registry asks the standard sidecar collector for every
config/automations.php in the tree, then merges what comes back.
The five facts that govern it
- The collector is
erpat_collect_module_config_arrays('automations') - The same function behind
menu,permissions,routes,widgetsandnotifications. It walksmodules/*, takes any directory shipping amodule.json, includesconfig/automations.phpif present, and returns a map keyed by module directory name —HumanResource, nothuman_resource. The result is memoised per request. - Order is core, then modules alphabetically — and first declaration wins
application/config/automations.phpis consulted first (no core types ship today; the seam exists so a core type would outrank a module one rather than silently losing). A later module declaring an already-registered type key is rejected and logged, never merged over. Pick a namespaced type key —attendance.auto_decision, notauto_decision.- Your
moduleslug gates availability - The registry reports a type unavailable while
module_<owner>is off, and the executor turns an unavailable type intoneeds_reviewrather than running it. This is what stops a module's automations outliving its toggle. The slug is the manifest slug (human_resource), not the directory name. - Discovery is ungated; availability is gated
- The collector reads your sidecar whether or not your module is enabled — that is why
it must stay side-effect free. The registry then applies the owner check when
answering
available()andis_available(). - The registry is fail-safe, not fail-closed
- A broken sidecar, a missing handler file, a class that will not load — each is
logged into
skipped()and surfaced in the Settings engine card, and the rest of the catalogue still works. That polarity is correct here because the registry only decides what a tenant may build; it is not an authorization surface. Your type simply will not appear, which is a support call, not an outage.
return array(...):
no CI calls, no get_instance(), no model loads, no queries, no
lang(). Put lang keys in it and let the UI resolve them.
The sidecar declaration
Here is the real one, from
modules/HumanResource/config/automations.php, trimmed of its comments:
defined('BASEPATH') or exit('No direct script access allowed');
return array(
'attendance.auto_decision' => array(
'module' => 'human_resource', // OWNER slug — gates availability
'definition_version' => 1,
'config_schema_version' => 1,
'label' => 'automation_type_attendance_auto_decision', // lang key
'description' => 'automation_type_attendance_auto_decision_desc', // lang key
'icon' => 'fa-check-square-o',
'handler' => array(
'file' => 'automation/Attendance_auto_decision_handler.php', // RELATIVE to the module
'class' => 'Attendance_auto_decision_handler',
),
'condition_provider' => array(
'file' => 'automation/Attendance_condition_provider.php',
'class' => 'Attendance_condition_provider',
),
'publish_permissions' => array(
'approve' => array('attendance_approval'),
'reject' => array('attendance_approval', 'attendance_update'),
),
'capabilities' => array(
'preview' => true, 'dry_run' => true, 'chunked' => true,
'manual_run' => true, 'live' => true,
),
'defaults' => array(
'action' => 'approve', 'run_mode' => 'dry_run',
'lookback_days' => 7, 'settle_hours' => 12,
'overtime_policy' => 'skip',
'batch_size' => 250, 'max_items_per_run' => 1000,
'first_live_run_limit' => 50, 'max_mutation_errors' => 10,
),
),
);
Only four things are validated by Automation_registry::validation_error():
the definition must be an array, module must be a non-empty string,
handler.class and handler.file must be non-empty strings, and
handler.file must not escape your module directory. Everything else is
accepted as declared.
publish_permissions— read by no code at all. The four Live preconditions do not consult it, so an operator holdingautomations_publishandautomations_go_livebut notattendance_approvalcan publish a Live attendance automation today. Declare the block anyway (it is the intended contract and it documents intent), but do not rely on it for authorization.defaults— never applied. The builder ships its own field defaults; these are documentation until the builder becomes registry-driven.capabilities— onlyliveis read, byAutomations::_live_refusal().preview,dry_run,chunkedandmanual_runare descriptive; declaring'chunked' => falsedoes not stop the executor chunking you.
definition_version and config_schema_version feed the
registry checksum and are both hardcoded to 1 when an automation is
saved (Automations::save()), so a stored automation does not yet record
which schema version it was written against.
The handler contract
Automation_handler_interface declares three methods. Your handler is a
plain class loaded by require_once, not a CI library — so it stays
unit-testable without the framework, and so the registry can load it from your module
without package-path games.
| Method | Called when | Must |
|---|---|---|
validate_config($config, $context) |
On save, on preview, and again at run time before anything is mutated | Return array('valid' => bool, 'errors' => array<field,message>).
Error values are lang keys the builder renders per field. |
preview($context, $config, $cursor, $limit) |
The builder's Preview button, and the Observe path | Perform zero mutations, and return the same shape as
execute_chunk(). |
execute_chunk($context, $config_snapshot, $cursor, $limit) |
Once per chunk by Automation_executor |
Process at most $limit candidates, then return so the
executor can check its time budget. Be resumable from $cursor and
idempotent per subject. |
evaluate_chunk(..., $apply); the only difference is whether a
decision is written. A preview that can disagree with the run it predicts is worse
than no preview at all — an operator promoted to Live on the strength of it.
The real return shape
The interface docblock lists seven keys. The executor reads nine. Two of them —
items and notices — are absent from the docblock and are the
two that produce everything an operator actually sees afterwards. Return all nine.
| Key | Type | What the executor does with it |
|---|---|---|
processed | int | Added to the run's candidate_count. |
succeeded | int | Counted as approved_count on a Live run, would_count on Observe. Never conflate the two — approved_count must only ever mean a record actually changed. |
skipped | int | Added to skipped_count. |
failed | int | Added to failed_count; feeds the max_mutation_errors cap. |
next_cursor | array|null | Persisted on the run row and handed back to your next chunk — including on the next tick, minutes later. |
has_more | bool | Whether to keep chunking. Return false to finish the run. |
reason_counts | array<code,int> | Summed across chunks into reason_counts_json — the run summary. |
items | array<array> | Undocumented in the interface. One row per record, written to automation_run_items. Omit it and the run reports totals with no per-record trail. |
notices | array<key,int> | Undocumented in the interface. Named counters summed across chunks and surfaced in the preview response. The attendance type uses overtime_would_be_zeroed to show a landmine as a number before anyone goes Live. |
Each items[] entry is the shape Automation_run_items_model::record_item()
consumes:
$result['items'][] = array(
'subject_type' => 'attendance', // your record type, free string
'subject_id' => (int) $candidate->id,
'outcome' => $outcome, // would_approve | approved | skipped | failed
'reason_code' => $reason, // MACHINE-READABLE, stable, SCREAMING_SNAKE
'reason_detail' => $detail, // array; JSON-encoded for the run log
);
overtime_would_be_zeroed — which no longer literally zeroes anything —
would orphan every past run's notice, which is why it was deliberately left alone.
Choose these names as if you cannot change them, because in practice you cannot.
Two optional methods, duck-typed
Neither is on the interface. Both are discovered with method_exists(), so
omitting them degrades cleanly rather than fataling.
count_candidates($config) |
Called by Automations::preview() to get the run's
denominator — the total your configuration matches, counted in SQL, not
by walking chunks. Without it the preview reports
total_candidates: null and the operator sees a sample with no
sense of scale. |
condition_catalogue() |
Called by Automations::_condition_catalogue() to feed the rule
builder. Return array('fields' => …, 'operators' => …) —
in practice a two-line delegation to your provider. Without it your type
appears in the builder with no rule fields. |
$context — an eight-field stdClass
The interface docblocks say @param object $context Automation_context.
There is no class called Automation_context. It is an anonymous
stdClass built by Automation_executor::context(). Type-hinting
the name in the docblock is harmless; type-hinting it in code is a fatal error.
// modules/Automations/libraries/Automation_executor.php
private function context($run, $automation)
{
$context = new stdClass();
$context->run_id = (int) $run->id;
$context->run_uuid = (string) $run->uuid;
$context->automation_id = (int) $automation->id;
$context->automation_uuid = (string) $automation->uuid;
$context->automation_title = (string) $automation->title;
$context->run_mode = (string) $automation->run_mode; // 'live' | 'dry_run'
$context->published_by = (int) $automation->published_by;
$context->correlation_id = (string) $run->correlation_id;
return $context;
}
$context is null during a builder preview.
Automations::preview() calls
$handler->preview(null, $config, null, $limit) and
$handler->validate_config($config, null). Every read must be guarded:
is_object($context) && isset($context->run_mode) ? … : 'dry_run'.
An unguarded $context->run_mode in preview() takes down the
Preview button — which is the one control an operator uses before trusting your
type at all.
Two further traps in this object:
- The title property is
automation_title, nottitle. Reading the wrong one fails silently — in the attendance type the approval still happens and only the audit remark comes out blank, which nobody notices for weeks. run_modeis the automation's current mode, not the mode the run was queued with. The executor deliberately re-stamps the run so a demotion to Observe takes effect immediately, including for runs already queued. Branch on$context->run_mode, never on anything you cached.
The condition provider
Automation_condition_provider_interface is the mechanism that keeps the
rule builder a configuration surface rather than a query language. It publishes an
allow-list; the engine refuses everything outside it.
The field catalogue, keyed by field key. Each entry declares
label (lang key), group (UI heading), type
(boolean|number|string|enum|multi_select), operators
(the ops allowed for this field) and, for closed value sets,
options.
// modules/HumanResource/automation/Attendance_condition_provider.php
'attendance.log_type' => array(
'label' => 'automation_field_log_type',
'group' => 'Attendance',
'type' => 'enum',
'operators' => array('EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN'),
'options' => array(
'schedule' => 'automation_log_type_schedule',
'overtime' => 'automation_log_type_overtime',
'workhour' => 'automation_log_type_workhour',
),
),
'attendance.record_age_hours' => array(
'label' => 'automation_field_record_age_hours',
'group' => 'Attendance',
'type' => 'number',
'operators' => array('GT', 'GTE', 'LT', 'LTE'),
),
Namespace your field keys by domain (attendance.,
payroll., employee.) — the keys land in stored rule trees
and in the builder's grouping.
The operators this provider understands, mapped to arity
(unary|binary|set). The engine refuses any operator absent from this
map, and any operator absent from the specific field's own
operators list. Both checks run; they are not redundant — the provider
map is the vocabulary, the field list is the grant.
public function operators()
{
return array(
'EQUALS' => 'binary', 'NOT_EQUALS' => 'binary',
'GT' => 'binary', 'GTE' => 'binary',
'LT' => 'binary', 'LTE' => 'binary',
'IN' => 'set', 'NOT_IN' => 'set',
'IS_TRUE' => 'unary', 'IS_FALSE' => 'unary',
);
}
Resolve every declared field for one candidate into comparable scalars. This
method is called once per record inside the chunk loop, so it must be
O(1) — it shapes values the handler already fetched, in
$resolved, and does not go looking for more.
public function resolve($candidate, $resolved)
{
// Everything expensive — locks, exemptions, pending requests, department
// membership — was resolved ONCE for the whole chunk by the handler and
// arrives in $resolved. This method only shapes it.
return array(
'attendance.log_type' => (string) $candidate->log_type,
'attendance.has_schedule' => ((int) $candidate->sched_id) > 0,
'attendance.worked_minutes' => $this->worked_minutes($candidate),
'payroll.date_locked' => $date_locked,
// ...
);
}
resolve() must never query. A single query here is one query
per record. Measured on a live tenant the attendance candidate pool reaches
112,000 pending rows; at that scale a per-record query is not slow, it is a
cron tick that never finishes. Pre-fetch in the handler with bounded, set-based
queries and pass the result in.
The shipped provider also documents what it deliberately omits, and that is worth copying as a habit: it does not offer a "pending attendance correction request" field because ERPat has no such feature, and it does not offer "missing time out" because 100% of pending rows already carry one — the handler enforces that structurally in its candidate query instead of offering an inert rule. An allow-list entry that can never change an outcome is worse than no entry: it reads as a working control.
The rule engine
Automation_rule_engine is the security boundary. A rule tree arrives from a
browser, so nothing in it is trusted. The engine never builds SQL, never touches the
database, and never sees a column name — it compares pre-resolved scalars. It is
CI-free and side-effect free, so it produces identical verdicts in preview and in a live
run by construction.
Tree shape
// group
array('operator' => 'AND'|'OR', 'children' => array( <node>, ... ))
// leaf
array('field' => '<key>', 'operator' => '<OP>', 'value' => <scalar|array>)
The ten operators
Comparison is deliberately typed-loose: booleans compare as booleans (so
"0", "false" and "" all mean false), and everything
else compares as trimmed strings — so an INT column and a form string agree without PHP 8's
numeric-string surprises. The numeric operators refuse non-numeric operands outright
rather than coercing them.
Validated twice, on purpose
-
At save time
validate_tree($tree, $provider)checks that every field exists in the catalogue, every operator is both in the engine's own set and offered by the provider and allowed for that specific field, arity matches, and enum values come from that field's option list. -
At run time, before anything is touched
The executor calls
validate_config()again against the current definition. A definition that changed under a stored configuration must stop the run (CONFIG_NEEDS_REVIEW), never be reinterpreted.
This is what that looks like in the shipped handler — note that the tenant's tree is the last thing consulted, and only after four rules the tenant cannot switch off:
// Mandatory baseline rules, in order of severity. These are the HANDLER's own,
// NOT the tenant's: an empty rule tree must never be what makes a record eligible.
if ($values['payroll.date_locked']) {
$outcome = 'skipped'; $reason = 'PAYROLL_LOCKED';
} elseif ($values['requests.schedule_change_pending']) {
$outcome = 'skipped'; $reason = 'SCHEDULE_CHANGE_PENDING';
} elseif ($settle_hours > 0 && $values['attendance.record_age_hours'] < $settle_hours) {
$outcome = 'skipped'; $reason = 'NOT_YET_SETTLED';
} elseif ($policy === self::OT_SKIP && $values['attendance.has_existing_overtime']) {
$outcome = 'skipped'; $reason = 'OVERTIME_PRESENT';
} elseif (is_array($conditions) && $conditions !== array()
&& !$this->engine->evaluate($conditions, $values)) {
$outcome = 'skipped'; $reason = 'RULE_NOT_MET';
}
Two more engine behaviours worth knowing. A field the provider did not resolve is
treated as not satisfied — failing closed, so an unresolvable field can never make
a record eligible. And failing_leaves() tells you which leaves failed,
which is how "not eligible" becomes a reason a person can act on instead of a bare
false; for an OR group it reports failures only when the whole group failed,
so a satisfied alternative is never blamed.
The mutation rule
Here is the concrete why. Approving an attendance record is not
UPDATE attendance SET status='approved'. That statement is syntactically
fine, runs instantly, and looks correct in the listing — and it skips the
attendance_metrics upsert that payroll consumes. The result is a record that
reads as approved everywhere a human looks and contributes nothing to pay. ERPat already
ships a repair tool for exactly this corruption
(checkfix/AttendanceMetricsCheck.php); an automation that produced it at
scale, unattended, nightly, would be the worst possible source.
So the handler calls AttendancesTrait::transition_attendance_status() — the
same service behind the Approve button. That service re-checks the payroll lock, the
rolling attendance window and shift overlaps for itself, against the row as it is right
now. That duplication with the handler's own rules is deliberate: the handler
evaluated a snapshot taken when the chunk was fetched, and a human may have locked the
period since. The service is the authority; its refusal overrides the handler's
verdict, not the other way round.
Refuse rather than write unattributed
Live writes are attributed to the System actor seeded by core migration
20260903232432_seed_system_actor_user.php and resolved by
erpat_system_actor_id(). That function returns 0 when the
migration has not run, and 0 must be treated as a refusal, not a fallback:
checked_by = 0 joins to nothing, so the approver renders blank in the
details modal, the export and the approval email — and one listing path falls back to
naming the record's own creator. The shipped handler throws:
if ($apply) {
$gateway = $this->gateway();
// Failing here is recoverable; a month of unattributable approvals is not.
if (!$gateway->can_attribute()) {
throw new Exception(
'Attendance_auto_decision_handler: no System actor on this database, so an '
. 'approval could not be attributed. Run the core migration '
. '20260903232432_seed_system_actor_user before enabling Live mode.'
);
}
}
The gateway pattern
Only needed when your mutation service lives on a trait written for controllers —
which in ERPat it usually does. A cron tick has no session, no
$this->login_user, no with_permission(), no
var_biometrics_option(). A gateway is a small host class that supplies
exactly the surface the trait expects, so the automation calls the same code the
button calls.
Two things about it look optional and are not:
__construct ›The trait ships a constructor that calls with_module(),
with_permission(), access_only_team_members() and
init_permission_checker() — all session-bound, all fatal headlessly.
A class's own method beats the trait's, so declaring one replaces it. Alias
the trait's away rather than leaving it reachable:
class Attendance_transition_gateway
{
use AttendancesTrait { __construct as private __attendancesTraitConstruct; }
public function __construct()
{
$this->CI =& get_instance();
$this->CI->load->model('Attendance_model');
$this->CI->load->model('Log_controls_model');
// ... the models and helpers the trait will reach for ...
$this->actor_id = function_exists('erpat_system_actor_id')
? (int) erpat_system_actor_id() : 0;
}
}
__get, and know what it does not cover ›load->model() attaches the model to the CI super-object, not
to your object. Copying get_object_vars($CI) into $this
captures a snapshot taken before those loads, so every model read comes back
null. Forwarding unknown reads to get_instance() is the
only shape that works — and it is the same seam CI_Loader uses for
views:
public function __get($name)
{
return isset($this->CI->$name) ? $this->CI->$name : null;
}
__get forwards PROPERTIES, not METHODS. Any method the trait
calls must be a real method on your gateway. That is why
var_biometrics_option() is declared explicitly (delegating to
erpat_biometrics_option(), so the automation and the Approve button
compute metrics from identical inputs by construction) — and it is why a
reachable with_permission() call would be a fatal
"Call to undefined method", not a harmless false.
with_permission() today only because
approve() sets three separate bypasses
(check_permissions => false,
metrics_override => false,
ot_permission => true), each covering a different trait call
site. Three flags all having to stay correct is a trip-wire — but the safe
return value for a declared with_permission() is not obvious
(false would silently queue every record's overtime as pending,
a quiet pay-withholding change). That call is recorded as §20.9 of the
implementation plan rather than made in passing.
The gateway's constructor loads five models and resolves the System actor. Built per record, a 250-record chunk repeats that 250 times. The shipped handler builds it once per chunk and lazily, so an Observe run never pays for it at all:
private function gateway()
{
if ($this->gateway === null) {
require_once __DIR__ . '/Attendance_transition_gateway.php';
$this->gateway = new Attendance_transition_gateway();
}
return $this->gateway;
}
Where authority comes from
A headless caller has no session and therefore no permissions. Its authority is the
automation's publisher, which the executor revalidates immediately before the run
via user_can($id, 'automations_go_live'), failing closed to
needs_review + PUBLISHER_UNAUTHORIZED. That is why the
transition service is called with check_permissions => false: the decision
was already made, one level up, against a real user.
user_has_permit() for this. It ignores its
$userid argument entirely and answers for the current session — which,
in a cron tick, is nobody. Use user_can($id, $permission).
Security non-negotiables
A tenant supplies configuration values validated against your registered schema — never a file path, class name, callable, SQL fragment, or field name. Your sidecar is the only thing that names a class, and it is source-controlled.
Never merge a post wholesale into a config.
Automations::_config_from_post() reads a fixed list, so a crafted extra
field cannot reach your handler or the stored configuration.
If a scope was configured but resolves to no one, return an
empty array, not null — the model turns that into
1=2. Treating an empty configured scope as "everyone" is the worst
available failure.
The registry rejects a handler.file containing
.. or resolving outside your module directory — before touching the
filesystem, so a non-existent traversal path is refused rather than merely "not
found".
Add to that: keep your sidecar side-effect free (it runs on every request); make
execute_chunk() idempotent per subject, because a lease can expire and a
chunk can be replayed; and never let one bad record abandon the chunk — catch, count it
as failed, and let the executor's max_mutation_errors cap decide
whether the run should stop.
Adding a type to your module
-
Confirm the mutation service exists headlessly
Before anything else. If your write path is controller-bound, extract it or plan the gateway. This decides whether the whole thing is a day or a fortnight.
-
Create
modules/<You>/config/automations.phpOne entry, namespaced type key, your manifest slug in
module, the two file/class blocks. Literal array only — no CI calls. -
Write the condition provider
fields(),operators(),resolve(). Declare only fields you can actually resolve, and only operators that make sense per field. Omit anything inert. -
Write the handler
validate_config(), then one privateevaluate_chunk($…, $apply)that bothpreview()andexecute_chunk()delegate to. Addcount_candidates()andcondition_catalogue()— they are optional to the interface and not optional to a usable feature. -
Enforce your baseline rules before the tenant's tree
An empty rule set must never be what makes a record eligible. Give every skip a stable reason code.
-
Pre-fetch chunk context in bounded set-based queries
Locks, memberships, related requests — resolved once per chunk, passed into
resolve(). Nothing queried inside the per-record loop. -
Return all nine keys, including
itemsandnoticesChoose reason codes and notice keys as if permanent, because stored run history makes them so.
-
Add your lang keys, then verify end to end
Labels, descriptions, field labels, enum options and every
automation_error_*yourvalidate_config()returns go in your own module'slanguage/english/<slug>_lang.php. Then: the type appears in the builder, Preview returns numbers, an Observe run completes and writes per-record items. -
Bump your module version and changelog
A new automation type is a new feature —
module.jsonMINOR plus a datedCHANGELOG.mdentry.
lookback_days, settle_hours, overtime_policy,
log_types). The Conditions panel is already registry-driven from
your condition_catalogue(), and the registry, executor, rule engine and
run tables are all type-agnostic. But a second type is the change that forces
those two panels to become registry-driven — which is exactly why a second type is the
right time to do it, and why it should be scoped into the work rather than discovered
during it. This is tracked in the plan's known-debt section.
Testing your type
The architecture is deliberately shaped so most of it is testable without a database.
Handlers, providers and the rule engine are plain classes loaded by
require_once — not CI libraries — precisely so a unit test can construct
them.
| Rule evaluation | Instantiate Automation_rule_engine directly, feed it a tree and a
values array. No CI, no DB. Cover the empty-tree-is-TRUE case explicitly — it
is the one that hurts. |
| Catalogue validation | validate_tree() against your provider: an unknown field, an
operator the field does not allow, an enum value outside the option list,
a set operator with an empty array. |
| Registry wiring | Automation_registry takes injectable seams —
module_collector, module_status,
modules_path, core_config — so you can assert
first-declaration-wins, owner gating and path containment with no filesystem
and no CI. |
| Preview/live parity | Assert structurally that both entry points reach one evaluator. The Automation Center's own suite does this by asserting on source text; it is crude and it catches the regression that matters. |
| Null context | Call preview(null, $config, null, $limit) and
validate_config($config, null) — the exact shapes the controller
uses. This is the cheapest test on this list and it catches the most common
integration break. |
Run your module's suite with php erpat module:test <YourModule>. Module
tests live in modules/<You>/tests/ and are never collected by the core
test:run suite.
Next: Run Lifecycle & Execution covers what
the executor does with what you return — claiming, leases, cursors, time budgets, caps,
and how a run reaches completed. The vocabularies your reason codes join are
in Data, Keys & Vocabularies, and the honest list of what
is not finished is in Research & Known Gaps.