Automation Center Reference Public

Contributing an Automation Type

How a module developer contributes a new automation type: the three files you ship, the declarative sidecar the registry collects, the handler and condition-provider contracts, the rule engine's allow-list, and the rule that every mutation must go through the owning module's own service.

Guide version: r1 Module version: 0.3.3 Updated: 2026-09-04 Estimated time: 24 min 1 views
Developer Guide

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.

Read this before you design. The two constraints that shape everything else are (1) registration is declarative — there is no 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.

FileRoleRequired
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.

Collector
glob modules/*, include each config/automations.php
Merge
core first, then modules alphabetically
Validate
owner slug, handler block, path containment
Available
only while module_<owner> is on

The five facts that govern it

The collector is erpat_collect_module_config_arrays('automations')
The same function behind menu, permissions, routes, widgets and notifications. It walks modules/*, takes any directory shipping a module.json, includes config/automations.php if present, and returns a map keyed by module directory nameHumanResource, not human_resource. The result is memoised per request.
Order is core, then modules alphabetically — and first declaration wins
application/config/automations.php is 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, not auto_decision.
Your module slug gates availability
The registry reports a type unavailable while module_<owner> is off, and the executor turns an unavailable type into needs_review rather 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() and is_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.
Your sidecar runs on every request. The collector is invoked from the settings hook, ungated by module state. Keep the file to a literal 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.

Three of these keys are declared and read by nothing. Do not assume they work.
  • publish_permissionsread by no code at all. The four Live preconditions do not consult it, so an operator holding automations_publish and automations_go_live but not attendance_approval can 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 — only live is read, by Automations::_live_refusal(). preview, dry_run, chunked and manual_run are descriptive; declaring 'chunked' => false does 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.

MethodCalled whenMust
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.
????
Preview and Live must share one evaluator. This is a contract requirement, not advice. In the shipped handler both methods are two lines that call one private 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.

KeyTypeWhat the executor does with it
processedintAdded to the run's candidate_count.
succeededintCounted 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.
skippedintAdded to skipped_count.
failedintAdded to failed_count; feeds the max_mutation_errors cap.
next_cursorarray|nullPersisted on the run row and handed back to your next chunk — including on the next tick, minutes later.
has_moreboolWhether to keep chunking. Return false to finish the run.
reason_countsarray<code,int>Summed across chunks into reason_counts_json — the run summary.
itemsarray<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.
noticesarray<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
);
A notice key is permanent once shipped. Notice keys and reason codes are written into stored run history. Renaming 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, not title. 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_mode is 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

EQUALSbinarytyped-loose compare
NOT_EQUALSbinarynegation of the above
GTbinarynumeric only
GTEbinarynumeric only
LTbinarynumeric only
LTEbinarynumeric only
INsetnon-empty array required
NOT_INsetnon-empty array required
IS_TRUEunaryno value
IS_FALSEunaryno value

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

  1. 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.

  2. 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.

An empty group evaluates TRUE — so the baseline rules are YOURS, not the engine's. "No constraints stated" correctly means "everything passes". That makes a merely-empty rule set the single most dangerous configuration in the system, and the engine cannot defend against it because it does not know what your records are. Your handler must enforce its own mandatory, non-negotiable rules before consulting the tenant's tree.

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

Call the owning module's authoritative service. Never raw SQL, never a direct model write, never a hand-rolled status update. This is the single hardest rule on this page and the one with the most expensive failure mode.

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.

The corollary, and it is the real cost of adding a type: if your module's mutation logic only exists inside a controller, extracting a headless-callable service is the prerequisite, not a follow-up. Duplicating the logic into your handler recreates the drift this architecture exists to remove — and the duplicate is the copy that runs unattended at 3 a.m.

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:

Trap 1 — declare your OWN __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;
    }
}
Trap 2 — forward with __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.

Documented compromise, owned by HumanResource. Nothing reaches 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.
Cost: build it once per chunk, lazily

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.

Never use 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

Tenant input is never code

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.

Read only known keys

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.

An empty scope means nobody

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.

Path containment

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

  1. 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.

  2. Create modules/<You>/config/automations.php

    One entry, namespaced type key, your manifest slug in module, the two file/class blocks. Literal array only — no CI calls.

  3. 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.

  4. Write the handler

    validate_config(), then one private evaluate_chunk($…, $apply) that both preview() and execute_chunk() delegate to. Add count_candidates() and condition_catalogue() — they are optional to the interface and not optional to a usable feature.

  5. 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.

  6. 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.

  7. Return all nine keys, including items and notices

    Choose reason codes and notice keys as if permanent, because stored run history makes them so.

  8. Add your lang keys, then verify end to end

    Labels, descriptions, field labels, enum options and every automation_error_* your validate_config() returns go in your own module's language/english/<slug>_lang.php. Then: the type appears in the builder, Preview returns numbers, an Observe run completes and writes per-record items.

  9. Bump your module version and changelog

    A new automation type is a new feature — module.json MINOR plus a dated CHANGELOG.md entry.

The builder is not yet generic — plan for UI work. The wizard's Action and Safety panels are hardcoded to the attendance type's fields (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.

Was this guide helpful?

Report a content problem