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.
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.
| Column | Type | Null | What it holds |
|---|---|---|---|
id | INT UNSIGNED AI | no | Primary key. |
uuid | VARCHAR(36) | no | Public-safe identifier. Feeds the run idempotency key, so it must never be reissued. |
type_key | VARCHAR(100) | no | Registry key, e.g. attendance.auto_decision. Resolved against the sidecar registry at run time, not stored by reference. |
definition_version | INT | no (1) | Hardcoded to 1 by Automations::save() — see the key reference below. |
config_schema_version | INT | no (1) | Also hardcoded to 1 on save. |
title | VARCHAR(500) | no | Operator-facing name. Cloning appends a numbered Copy suffix. |
description | TEXT | yes | Free text. |
status | VARCHAR(20) | no (draft) | draft · active · paused · needs_review · archived. |
run_mode | VARCHAR(10) | no (dry_run) | dry_run (Observe) or live. The current setting only. |
trigger_type | VARCHAR(20) | no (schedule) | Always schedule today. There is no event bus. |
cron_expr | VARCHAR(100) | yes | Validated by the shared App\Cron\Core\CronExpression, never by a private parser. |
schedule_timezone | VARCHAR(64) | no (Asia/Manila) | An IANA zone name — never a bare UTC offset, which cannot express DST. |
next_run_at_utc | DATETIME | yes | Precomputed due pointer. This is what makes a missed window run the latest occurrence once instead of replaying a backlog. |
overlap_policy | VARCHAR(20) | no (forbid) | forbid or queue_latest. |
scope_json | TEXT | yes | Who the rule applies to. Normalized to member:<id> / team:<id> tokens before storage. |
conditions_json | TEXT | yes | The rule tree, validated against the type's condition catalogue. |
action_json | TEXT | yes | What to do with a matching record. |
safety_json | TEXT | yes | Caps and locks: batch size, per-run maximum, error ceiling, the rolling attendance lock. |
notification_json | TEXT | yes | Reserved. Notification delivery is Milestone 4. |
config_hash | VARCHAR(64) | yes | SHA-256 over the configuration blocks. The drift control behind both preview validity and Live promotion. |
revision | INT | no (1) | Bumped on every save. |
published_by | INT | yes | The authority anchor. Revalidated at run time for Live runs. |
published_at | DATETIME | yes | When it was last published. |
last_run_id last_run_status last_run_at | INT / VARCHAR(30) / DATETIME | yes | Point-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.
| Column | Type | Null | What it holds |
|---|---|---|---|
uuid | VARCHAR(36) | no | Public-safe run identifier (unique). |
automation_id | INT | no | Owning rule. |
idempotency_key | VARCHAR(64) | no | SHA-256 of uuid | scheduled_for_utc | trigger_kind. Unique. See below. |
trigger_kind | VARCHAR(20) | no (schedule) | schedule · manual · preview. |
run_mode | VARCHAR(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_utc | DATETIME | yes | The occurrence this run represents. |
scheduled_for_local | VARCHAR(40) | yes | Display snapshot of the local occurrence, so a later timezone edit does not rewrite history. |
started_at finished_at duration_ms schedule_delay_ms | DATETIME / INT | yes | Timing. schedule_delay_ms is how late the tick was, which is the number that tells you the cron engine is struggling. |
status | VARCHAR(30) | no (queued) | Eight values — see the vocabulary lookup below. |
attempt | INT | no (0) | Incremented by start_run(); a resumed run is a second attempt of the same run, not a new one. |
lease_owner lease_expires_at | VARCHAR(64) / DATETIME | yes | The 600-second lease. Lets a dead worker's run be reclaimed rather than stranded, mirroring the cron runtime's own sweeper. |
cursor_json | TEXT | yes | Deterministic resume position when a run exhausts its tick budget. |
config_snapshot_json | MEDIUMTEXT | yes | Immutable, point-in-time by design. What the rule was when it became due. |
config_hash | VARCHAR(64) | yes | Hash of that snapshot. Compared against the automation's current hash to decide whether a completed run still authorises Live. |
correlation_id | VARCHAR(64) | yes | Ties 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_count | INT | no (0) | Counters. In Observe mode a success lands in would_count; in Live it lands in approved_count. |
reason_counts_json | TEXT | yes | Reason-code histogram, aggregated in SQL rather than by materializing every item in PHP. |
error_code | VARCHAR(64) | yes | Machine-readable failure cause. |
error_summary | VARCHAR(1000) | yes | Redacted. 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.
| Column | Type | Null | What it holds |
|---|---|---|---|
run_id | INT | no | Owning run. |
subject_type | VARCHAR(40) | no (attendance) | What kind of record this decision was about. |
subject_id | INT | no | The record's id in its own table. |
outcome | VARCHAR(30) | no | One of seven values, validated against automations_item_outcomes() before insert. |
reason_code | VARCHAR(64) | yes | Why. Machine-readable; the vocabulary is discussed below. |
reason_detail_json | TEXT | yes | Structured and redacted — never a full record snapshot. |
attempt | INT | no (0) | Which run attempt produced this item. |
system_log_id | INT | yes | Link to the authoritative business audit row for the mutation. |
duration_ms | INT | yes | Per-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_keySHA256(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)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.
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:
| Vocabulary | Function | Enforced where |
|---|---|---|
| Automation status | automations_statuses() | Validated on status transitions. |
| Run status | automations_run_statuses() | Validated when a run is finished. |
| Item outcome | automations_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 code | automations_reason_codes() | Nowhere. It is a declared list with no validator, and the shipped attendance handler emits five codes outside it. |
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.
| URI | Controller method | Extra permission |
|---|---|---|
automations | index | — |
automations/overview_tab | overview_tab | — |
automations/list_tab | list_tab | — |
automations/runs_tab | runs_tab | automations_logs |
automations/analytics_tab | analytics_tab | automations_analytics |
automations/settings_tab | settings_tab | automations_settings |
automations/list_data | list_data | — |
automations/runs_data | runs_data | automations_logs |
automations/builder | builder | automations_update when editing, else automations_create |
automations/preview | preview | automations_create |
automations/save | save | automations_update when editing, else automations_create |
automations/clone | clone_automation | automations_create |
automations/publish | publish | automations_publish |
automations/set_run_mode | set_run_mode | automations_update, plus automations_go_live to move to Live |
automations/pause | pause | automations_update |
automations/resume | resume | automations_update |
automations/archive | archive | automations_delete |
automations/run_now | run_now | automations_run_now |
automations/run_detail | run_detail | automations_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 key | Severity | Written by |
|---|---|---|
created:automation | info | save() for a new rule; clone_automation() |
updated:automation | info | save() for an edit; resume() |
published:automation | warning | publish(); set_run_mode() |
paused:automation | warning | pause() |
archived:automation | warning | archive() |
ran:automation | info | Automation_executor, exactly once per completed run |
settings:automation | warning | no writer yet — Milestone 4 |
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_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.
| Function | Returns | Notes |
|---|---|---|
automations_uuid4() | 36-char UUID | RFC 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 hex | The key behind constraint ①. Revision deliberately excluded. |
automations_correlation_id() | corr_ + 16 hex | Correlates dispatcher output, run, items and downstream audit rows. |
automations_statuses() | 5 strings | Automation lifecycle statuses. |
automations_run_statuses() | 8 strings | Run statuses. |
automations_item_outcomes() | 7 strings | Enforced by record_item(). |
automations_reason_codes() | 17 strings | Declared vocabulary. Not enforced anywhere — see the drift note above. |
automations_normalize_scope_members($raw) | normalized token string | Keeps 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.
| Path | Lines | What it is |
|---|---|---|
module.json | — | Manifest. Discovery gate, version 0.3.2, and the known_risks block operators read in Manage Modules. |
config/automations.php | 71 | The sidecar contract, documented at its owner. Returns an empty array: this module declares no types of its own. |
config/permissions.php | 55 | The 10 RBAC keys. |
config/routes.php | 48 | All 20 route keys. |
config/system_logs.php | 77 | The 7 audit events. |
config/menu.php · default_menu.php | 35 · 28 | Sidebar slice (Administration, between System Logs and Settings) and the default-menu slice. |
config/module_config.php | 19 | Manage Modules registration. module_key must equal the manifest slug. |
controllers/Automations.php | 1097 | The single controller. Every route, every permission gate, the builder, the lifecycle transitions. |
models/Automations_model.php | 275 | Rule definitions. All SQL for this module lives in the models. |
models/Automation_runs_model.php | 405 | Run creation, leasing, progress, finishing, and the Live-precondition lookups. |
models/Automation_run_items_model.php | 195 | Per-record items and the SQL-side reason/outcome aggregates. |
libraries/Automation_registry.php | 539 | Sidecar collection, validation, availability gating, path containment, checksum. |
libraries/Automation_executor.php | 497 | Lease, validate, revalidate publisher authority, chunk, record, finish. |
libraries/Automation_rule_engine.php | 311 | Condition-tree validation and evaluation. No eval, no SQL from tenant input. |
libraries/contracts/*.php | 64 · 62 | The handler and condition-provider interfaces a contributing module implements. |
helpers/automations_helper.php | 169 | Identity, keys and vocabularies. |
jobs/AutomationDispatchJob.php | 378 | The module's one cron job, automation_dispatch, * * * * *, per-tenant. |
views/automations/index.php | 121 | The five-tab shell. |
views/automations/builder_modal.php | 528 | The wizard. Its Action and Safety panels are still hardcoded to the attendance type's fields. |
views/automations/run_detail_modal.php | 181 | Per-run item drill-down. |
views/automations/tabs/*.php | 46–134 | Overview, Automations, Runs & Logs, Analytics, Settings. |
migrations/*.php | 224 · 86 · 79 | Create tables; seed the tenant toggle; add run_mode to runs. |
seeders/AutomationsDemoSeeder.php | 231 | Demo data, with a matching unseed() so it is reversible. |
tests/AutomationsTest.php | 945 | Module contract: manifest, gating, registry validation, path containment, availability. |
tests/AutomationsEngineTest.php | 268 | Rule-engine behaviour: unknown fields rejected, unresolved fields never satisfy, empty tree permissive. |
language/english/automations_lang.php | 221 | Module-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:
| Path | Lines | What it is |
|---|---|---|
modules/HumanResource/config/automations.php | 111 | Declares attendance.auto_decision. |
modules/HumanResource/automation/Attendance_auto_decision_handler.php | 588 | Decides and mutates. Owned by the module that owns attendance. |
modules/HumanResource/automation/Attendance_condition_provider.php | 212 | The field catalogue the builder offers. |
modules/HumanResource/automation/Attendance_transition_gateway.php | 222 | Routes the mutation through the same service the Approve button calls. |
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.