Automation Center Reference Public

Data, Keys & Vocabularies

Look up the Automation Center's three tenant tables and their columns, the two unique indexes that make runs idempotent, every route and its permission gate, the seven audit events, the helper functions, and the full module file map.

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

Data, Keys & Vocabularies

Everything you look up rather than read: the three tables and their columns, the two indexes that do the module's hardest work, the eighteen endpoints and what each one demands of you, the seven audit events, and where every file lives.

3
Tenant tables
18
Named endpoints
7
Audit events
10
Permission keys

The three tables

All three live in the tenant database only. There is deliberately no provider-side or main-database routing index: ERPat's Advanced Cron Tick already fans the dispatch job out once per tenant with the connection switched, so the job reads whatever the current tenant's own automations table holds. Engine liveness is answered by the existing /cron/health endpoint and the shared cron_runs table — this module does not build a second one.

They are created by migrations/20260903124527_create_automation_tables.php, which runs against the primary database and every tenant database through php erpat migrate:modules. Every table ends with the standard ERPat audit block (created_by, created_at, updated_at, deleted) and every read filters deleted = 0.

automations — the rule definitions

One row per rule a tenant has authored. This is the current state of the rule; it is edited freely and it is not what a historical run is judged against.

ColumnTypeNullWhat it holds
idINT UNSIGNED AInoPrimary key.
uuidVARCHAR(36)noPublic-safe identifier. Feeds the run idempotency key, so it must never be reissued.
type_keyVARCHAR(100)noRegistry key, e.g. attendance.auto_decision. Resolved against the sidecar registry at run time, not stored by reference.
definition_versionINTno (1)Hardcoded to 1 by Automations::save() — see the key reference below.
config_schema_versionINTno (1)Also hardcoded to 1 on save.
titleVARCHAR(500)noOperator-facing name. Cloning appends a numbered Copy suffix.
descriptionTEXTyesFree text.
statusVARCHAR(20)no (draft)draft · active · paused · needs_review · archived.
run_modeVARCHAR(10)no (dry_run)dry_run (Observe) or live. The current setting only.
trigger_typeVARCHAR(20)no (schedule)Always schedule today. There is no event bus.
cron_exprVARCHAR(100)yesValidated by the shared App\Cron\Core\CronExpression, never by a private parser.
schedule_timezoneVARCHAR(64)no (Asia/Manila)An IANA zone name — never a bare UTC offset, which cannot express DST.
next_run_at_utcDATETIMEyesPrecomputed due pointer. This is what makes a missed window run the latest occurrence once instead of replaying a backlog.
overlap_policyVARCHAR(20)no (forbid)forbid or queue_latest.
scope_jsonTEXTyesWho the rule applies to. Normalized to member:<id> / team:<id> tokens before storage.
conditions_jsonTEXTyesThe rule tree, validated against the type's condition catalogue.
action_jsonTEXTyesWhat to do with a matching record.
safety_jsonTEXTyesCaps and locks: batch size, per-run maximum, error ceiling, the rolling attendance lock.
notification_jsonTEXTyesReserved. Notification delivery is Milestone 4.
config_hashVARCHAR(64)yesSHA-256 over the configuration blocks. The drift control behind both preview validity and Live promotion.
revisionINTno (1)Bumped on every save.
published_byINTyesThe authority anchor. Revalidated at run time for Live runs.
published_atDATETIMEyesWhen it was last published.
last_run_id last_run_status last_run_atINT / VARCHAR(30) / DATETIMEyesPoint-in-time run pointers. A documented snapshot exception to single-source-of-truth, with exactly one writer: Automation_executor.

Indexes: uniq_automations_uuid (unique), idx_automations_due on (status, next_run_at_utc, deleted) — the one the dispatcher's due-query rides — idx_automations_type on (type_key, status), and idx_automations_deleted.

automation_runs — one row per occurrence

One row per scheduled, manual or preview occurrence. A run is immutable in intent: it carries its own snapshot of the configuration it was claimed under, so reading history never depends on what the rule looks like today.

ColumnTypeNullWhat it holds
uuidVARCHAR(36)noPublic-safe run identifier (unique).
automation_idINTnoOwning rule.
idempotency_keyVARCHAR(64)noSHA-256 of uuid | scheduled_for_utc | trigger_kind. Unique. See below.
trigger_kindVARCHAR(20)no (schedule)schedule · manual · preview.
run_modeVARCHAR(10)no (dry_run)What this run did — added by migration 20260903233638 and backfilled to dry_run, because that is what every earlier run genuinely was.
scheduled_for_utcDATETIMEyesThe occurrence this run represents.
scheduled_for_localVARCHAR(40)yesDisplay snapshot of the local occurrence, so a later timezone edit does not rewrite history.
started_at finished_at duration_ms schedule_delay_msDATETIME / INTyesTiming. schedule_delay_ms is how late the tick was, which is the number that tells you the cron engine is struggling.
statusVARCHAR(30)no (queued)Eight values — see the vocabulary lookup below.
attemptINTno (0)Incremented by start_run(); a resumed run is a second attempt of the same run, not a new one.
lease_owner lease_expires_atVARCHAR(64) / DATETIMEyesThe 600-second lease. Lets a dead worker's run be reclaimed rather than stranded, mirroring the cron runtime's own sweeper.
cursor_jsonTEXTyesDeterministic resume position when a run exhausts its tick budget.
config_snapshot_jsonMEDIUMTEXTyesImmutable, point-in-time by design. What the rule was when it became due.
config_hashVARCHAR(64)yesHash of that snapshot. Compared against the automation's current hash to decide whether a completed run still authorises Live.
correlation_idVARCHAR(64)yesTies the dispatcher output, this run, its items and the downstream business audit rows together.
candidate_count approved_count rejected_count would_count skipped_count failed_countINTno (0)Counters. In Observe mode a success lands in would_count; in Live it lands in approved_count.
reason_counts_jsonTEXTyesReason-code histogram, aggregated in SQL rather than by materializing every item in PHP.
error_codeVARCHAR(64)yesMachine-readable failure cause.
error_summaryVARCHAR(1000)yesRedacted. Never a raw database error — a driver message can carry table names and values into a screen an operator can read.

Indexes: uniq_automation_runs_idem (unique), uniq_automation_runs_uuid (unique), idx_automation_runs_sched on (automation_id, scheduled_for_utc), idx_automation_runs_status on (status, started_at), idx_automation_runs_corr on (correlation_id), and idx_automation_runs_mode on (automation_id, run_mode, status) — which exists so that "has this automation ever completed a Live run?" is one indexed lookup, not a scan.

automation_run_items — one row per record decided

The per-record trail. Note what it does not contain: a copy of the decided record. It stores a link (system_log_id) to the authoritative business audit row instead, so the record's own history stays owned by the module that owns the data.

ColumnTypeNullWhat it holds
run_idINTnoOwning run.
subject_typeVARCHAR(40)no (attendance)What kind of record this decision was about.
subject_idINTnoThe record's id in its own table.
outcomeVARCHAR(30)noOne of seven values, validated against automations_item_outcomes() before insert.
reason_codeVARCHAR(64)yesWhy. Machine-readable; the vocabulary is discussed below.
reason_detail_jsonTEXTyesStructured and redacted — never a full record snapshot.
attemptINTno (0)Which run attempt produced this item.
system_log_idINTyesLink to the authoritative business audit row for the mutation.
duration_msINTyesPer-record time.

Indexes: uniq_automation_items_subject (unique — see below), idx_automation_items_outcome on (run_id, outcome), idx_automation_items_subject on (subject_type, subject_id) — the "what has automation ever done to this record?" index — and idx_automation_items_reason.

The two constraints that do the real work

Most of this module's correctness under concurrency is not in PHP. It is in two UNIQUE indexes, and both are written to as INSERT IGNORE so that the database arbitrates rather than a check-then-write that has a race between the two halves.

automation_runs.idempotency_key
One due occurrence becomes exactly one run, however many times the dispatcher is invoked for that minute. The key is SHA256(automation uuid | scheduled_for_utc | trigger_kind). The configuration revision is deliberately excluded: one scheduled occurrence stays one occurrence even when somebody edits the rule after it became due. Including the revision would let a mid-tick edit manufacture a second run of the same moment.
(run_id, subject_type, subject_id)
Per-run decision dedupe. A run that exhausted its tick budget and resumed may re-observe records it already processed; because of this index that is a silent no-op instead of a second decision on the same record. This is the constraint that makes resumability safe to have at all.
Why INSERT IGNORE and not a SELECT first. Two cron workers can reach the same due automation in the same second. A SELECT … then INSERT would let both find nothing and both insert; INSERT IGNORE plus a UNIQUE index means the second one is discarded by the engine. create_run() reads affected_rows() to learn whether it was the creator, then re-selects the row by key so both workers end up holding the same run id.
// modules/Automations/models/Automation_runs_model.php — create_run()
// INSERT IGNORE: the UNIQUE(idempotency_key) index is the real guard, so this
// stays correct under concurrency where a SELECT-then-INSERT would not.
$this->db->query(
    "INSERT IGNORE INTO `$runs` (" . implode(',', $columns) . ") VALUES (" . implode(',', $values) . ");"
);

$created = $this->db->affected_rows() > 0;

$existing = $this->db->query(
    "SELECT `id` FROM `$runs` WHERE `idempotency_key`=" . $this->db->escape($idempotency_key) . " LIMIT 1;"
)->row();

Sidecar key reference

Every key a module may declare in its config/automations.php sidecar, with the one column that actually decides whether writing it changes anything: who reads it. Filter to Declared but unconsumed to see the keys the contract documents and the code never looks at — that is not an omission in this guide, it is the finding, and it is why publish_permissions currently gates nothing. Filter to Decorative for keys that are read but whose value is overridden on save.

Interactive key reference — needs JavaScript. The full contract is documented in modules/Automations/config/automations.php.

Do not read the unconsumed keys as "reserved for later, harmless now". publish_permissions reads like a security control and is not one: an operator holding automations_publish and automations_go_live can today publish a Live attendance automation without holding attendance_approval. Until that key has a consumer, the compensating control is who you grant automations_go_live to. See Permissions, Authority & Scheduling.

Statuses, outcomes and codes

Four vocabularies are declared in helpers/automations_helper.php and a fifth — run error codes — is emitted by the executor without a declaring function. Use the lookup to find what a code on a screen means, and the Show only drift toggle to see where declaration and reality disagree: codes declared but never emitted by any shipped handler, and codes emitted by the shipped handler that no declaration lists.

Interactive code lookup — needs JavaScript. The vocabularies are defined in modules/Automations/helpers/automations_helper.php.

Three of those vocabularies are enforced and one is not, and the difference matters when you are writing a handler:

VocabularyFunctionEnforced where
Automation statusautomations_statuses()Validated on status transitions.
Run statusautomations_run_statuses()Validated when a run is finished.
Item outcomeautomations_item_outcomes()Hard gate. record_item() returns false for anything outside the list — silently, so a typo yields a run with correct counters and an empty detail modal.
Reason codeautomations_reason_codes()Nowhere. It is a declared list with no validator, and the shipped attendance handler emits five codes outside it.
The reason-code gap is deliberate for now, and it is owned. Constraining reason codes at insert time would mean a handler that emits an unlisted code loses its explanation while still recording its decision — strictly worse for the reader than an unlisted-but-present code. The chosen fix is to widen the declared list to match what handlers actually emit, not to start rejecting codes. Until then, treat automations_reason_codes() as documentation, not as a contract you can validate against. Tracked in Research & Known Gaps.

Routes and their permission gates

All routes live in modules/Automations/config/routes.php — never in core application/config/routes.php, where a stranded copy would permanently shadow the module's own declaration. The file returns 20 array keys: 18 named endpoints, the canonical entry, and a catch-all that must stay last so it does not swallow future fragments.

Every route additionally passes the constructor's two gates first: with_module("automations") then with_permission("automations"). The column below lists what each endpoint demands on top of those.

URIController methodExtra permission
automationsindex
automations/overview_taboverview_tab
automations/list_tablist_tab
automations/runs_tabruns_tabautomations_logs
automations/analytics_tabanalytics_tabautomations_analytics
automations/settings_tabsettings_tabautomations_settings
automations/list_datalist_data
automations/runs_dataruns_dataautomations_logs
automations/builderbuilderautomations_update when editing, else automations_create
automations/previewpreviewautomations_create
automations/savesaveautomations_update when editing, else automations_create
automations/cloneclone_automationautomations_create
automations/publishpublishautomations_publish
automations/set_run_modeset_run_modeautomations_update, plus automations_go_live to move to Live
automations/pausepauseautomations_update
automations/resumeresumeautomations_update
automations/archivearchiveautomations_delete
automations/run_nowrun_nowautomations_run_now
automations/run_detailrun_detailautomations_logs
automations/(:any)catch-all → Automations/$1

Two naming notes worth knowing before you go looking. clone is a PHP keyword; it is legal as a method name in PHP 7+, but the method is called clone_automation so the URL stays readable without depending on that. And there is no settings-save route: the Settings tab is a read-only registry inspector today. Its write path is Milestone 4, which is why the settings:automation audit event below is declared and not yet emitted.

System-activity log events

Declared in config/system_logs.php and merged into the core system_logs_config at bootstrap, ungated by active state so that historical entries from a disabled module still resolve their labels and filter dropdowns. Keys are <field_name>:<module_name>, matching exactly what is passed to set_system_logs().

Event keySeverityWritten by
created:automationinfosave() for a new rule; clone_automation()
updated:automationinfosave() for an edit; resume()
published:automationwarningpublish(); set_run_mode()
paused:automationwarningpause()
archived:automationwarningarchive()
ran:automationinfoAutomation_executor, exactly once per completed run
settings:automationwarningno writer yet — Milestone 4
One audit row per run, never one per record. A run that decides 263 attendance records writes one ran:automation entry. The per-record trail is automation_run_items, and the authoritative business audit for each approval is written by the module that owns attendance, through the same transition service the Approve button calls. If you ever find yourself adding a set_system_logs() call inside the per-record loop, you are duplicating an audit trail that already exists somewhere better.

Run entries are attributed to the System actor seeded by core migration application/migrations/20260903232432_seed_system_actor_user.php and resolved by erpat_system_actor_id(), which is cached per database because one cron tick walks several tenants in a single process. It returns 0 when the actor is unseeded, and 0 must be treated as a refusal, not as a fallback: an approval with a blank approver is worse than an approval that did not happen.

Permission keys

Ten keys, all declared explicitly in config/permissions.php — including every child. Omitting a child would let the Roles editor's prefix auto-expansion silently swallow every later key sharing the automations prefix.

automations automations_create automations_update automations_delete automations_publish automations_go_live automations_run_now automations_logs automations_analytics automations_settings

automations_go_live is highlighted because it is the one that lets records change unattended, and because it is the key whose holder's authority is revalidated at run time. Full semantics, including why publishing and going live are two separate permissions, are in Permissions, Authority & Scheduling.

Note what is not here: attendance decision permissions. An attendance automation reuses the existing attendance_approval and attendance_update keys rather than forking parallel ones. The module must never invent a second way to be allowed to approve attendance.

Helper functions

helpers/automations_helper.php is loadable, not auto-loaded: every consumer calls $this->load->helper('automations') explicitly. It is kept free of CI state so the identity and key functions can be unit-tested directly, which is why they live here rather than in the controller.

FunctionReturnsNotes
automations_uuid4()36-char UUIDRFC 4122 v4. ERPat has no shared UUID helper (Finance carries a private copy), so the module declares one implementation here instead of repeating it in three models.
automations_run_idempotency_key($uuid, $scheduled_for_utc, $trigger_kind)64-char hexThe key behind constraint ①. Revision deliberately excluded.
automations_correlation_id()corr_ + 16 hexCorrelates dispatcher output, run, items and downstream audit rows.
automations_statuses()5 stringsAutomation lifecycle statuses.
automations_run_statuses()8 stringsRun statuses.
automations_item_outcomes()7 stringsEnforced by record_item().
automations_reason_codes()17 stringsDeclared vocabulary. Not enforced anywhere — see the drift note above.
automations_normalize_scope_members($raw)normalized token stringKeeps only well-formed member:<id> / team:<id> tokens; anything else is dropped.

The last one carries the module's clearest statement about where the trust boundary sits, and it is worth reading in the original:

// modules/Automations/helpers/automations_helper.php
//
// The builder's scope picker is GUIDANCE, NOT SECURITY — like every other wizard
// field, its value arrives from a browser and is revalidated here before it can be
// stored. Anything unrecognised is DROPPED rather than persisted, so a crafted post
// cannot smuggle arbitrary text into scope_json ahead of the consumers that will
// read it.
//
// Duplicates collapse and order is preserved, so re-saving an unchanged selection
// produces an unchanged config hash and does not invalidate an approved preview.

That second paragraph is the non-obvious half: normalization is order-preserving and idempotent on purpose, because the config hash is computed over the stored blocks. A normalizer that reordered tokens would change the hash on a no-op save and quietly invalidate a preview the operator had already approved.

File map

The module owns its entire surface — routes, permissions, menu, migrations, cron job, tests, docs. Nothing about the Automation Center lives in application/ except the two things core genuinely owns: the module_automations setting on the primary database, and the System actor user.

PathLinesWhat it is
module.jsonManifest. Discovery gate, version 0.3.2, and the known_risks block operators read in Manage Modules.
config/automations.php71The sidecar contract, documented at its owner. Returns an empty array: this module declares no types of its own.
config/permissions.php55The 10 RBAC keys.
config/routes.php48All 20 route keys.
config/system_logs.php77The 7 audit events.
config/menu.php · default_menu.php35 · 28Sidebar slice (Administration, between System Logs and Settings) and the default-menu slice.
config/module_config.php19Manage Modules registration. module_key must equal the manifest slug.
controllers/Automations.php1097The single controller. Every route, every permission gate, the builder, the lifecycle transitions.
models/Automations_model.php275Rule definitions. All SQL for this module lives in the models.
models/Automation_runs_model.php405Run creation, leasing, progress, finishing, and the Live-precondition lookups.
models/Automation_run_items_model.php195Per-record items and the SQL-side reason/outcome aggregates.
libraries/Automation_registry.php539Sidecar collection, validation, availability gating, path containment, checksum.
libraries/Automation_executor.php497Lease, validate, revalidate publisher authority, chunk, record, finish.
libraries/Automation_rule_engine.php311Condition-tree validation and evaluation. No eval, no SQL from tenant input.
libraries/contracts/*.php64 · 62The handler and condition-provider interfaces a contributing module implements.
helpers/automations_helper.php169Identity, keys and vocabularies.
jobs/AutomationDispatchJob.php378The module's one cron job, automation_dispatch, * * * * *, per-tenant.
views/automations/index.php121The five-tab shell.
views/automations/builder_modal.php528The wizard. Its Action and Safety panels are still hardcoded to the attendance type's fields.
views/automations/run_detail_modal.php181Per-run item drill-down.
views/automations/tabs/*.php46–134Overview, Automations, Runs & Logs, Analytics, Settings.
migrations/*.php224 · 86 · 79Create tables; seed the tenant toggle; add run_mode to runs.
seeders/AutomationsDemoSeeder.php231Demo data, with a matching unseed() so it is reversible.
tests/AutomationsTest.php945Module contract: manifest, gating, registry validation, path containment, availability.
tests/AutomationsEngineTest.php268Rule-engine behaviour: unknown fields rejected, unresolved fields never satisfy, empty tree permissive.
language/english/automations_lang.php221Module-owned strings. Never re-declare a core key here.

The one shipped automation type is not in this module, and that is the whole architectural point:

PathLinesWhat it is
modules/HumanResource/config/automations.php111Declares attendance.auto_decision.
modules/HumanResource/automation/Attendance_auto_decision_handler.php588Decides and mutates. Owned by the module that owns attendance.
modules/HumanResource/automation/Attendance_condition_provider.php212The field catalogue the builder offers.
modules/HumanResource/automation/Attendance_transition_gateway.php222Routes the mutation through the same service the Approve button calls.
Run php erpat module:test Automations to exercise both suites. They are not collected by the core php erpat test:run, which only scans application/tests/unit.
Was this guide helpful?

Report a content problem