Automation Center Reference Public

Permissions, Authority & Scheduling

Who may let ERPat act on its own: the ten Automation Center permissions, why going live is a separate grant, how publisher authority is revalidated every tick and fails closed, the System actor that signs unattended approvals, cron scheduling, and the audit trail.

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

Permissions, Authority & Scheduling

This page is for the person who is accountable for the system acting without a human in the room. It covers who may build, publish and promote an automation, whose authority an unattended run borrows, who the approvals are attributed to, how the schedule actually fires, and what is written down afterwards.

10
Permissions
1
Cron job
7
Audit events
1
Log row per run
What exists today. Milestones 1–3 have shipped: the module, the builder, Observe runs, Live promotion and cloning. Milestone 4 has not — there are no automation notifications and no API surfaces yet. Nothing emails you when an automation moves to needs review; you find out by looking. Plan for that in your operating routine until M4 lands.

The ten permissions

The module owns its RBAC keys in modules/Automations/config/permissions.php; nothing is hardcoded in core. They appear in the Roles editor only while module_automations is enabled, and they disappear again when it is switched off — which is the whole reason a module declares its own permissions rather than adding rows to core.

Permission What it grants Who should hold it
automations Reaches the Automation Center page and the Overview tab. Everything else is gated on top of this. Declared default_level => 'module', so it renders as the module's Enable toggle in the Roles editor. Anyone who needs to see what is scheduled.
automations_create Creates a draft, opens the builder for a new rule, runs a preview, and clones an existing automation into a fresh draft. Process owners who design rules.
automations_update Edits a draft, pauses, resumes, and changes the run mode. Demoting Live → Observe needs only this; promoting the other way needs more (below). The same process owners.
automations_delete Archives an automation. Archive is the terminal state — nothing is hard-deleted, so the run history stays readable. Administrators.
automations_publish Publishes a draft to active, so it starts firing on its schedule. In Observe mode that means it starts reporting. A supervisor, not every builder.
automations_go_live Promotes an automation to Live — lets it change real records unattended. Checked at promotion time and revalidated against the publisher on every live tick. The smallest possible group. Usually one or two people.
automations_run_now Queues a manual run. It still creates an ordinary run row and goes through the executor on the next tick — HTTP never calls a handler directly. Operators who need an off-schedule pass.
automations_logs The Runs & Logs tab and the run detail modal. Anyone who has to answer "why did it decide that?" — auditors included.
automations_analytics The Analytics tab. Supervisors and process owners.
automations_settings The Settings tab: engine diagnostics, the registered-type inventory and tenant defaults. Administrators only.
The ten keys are frozen, and every child stays explicitly declared. Permission keys are serialized into users.permissions by name, so renaming one silently revokes it for everybody who holds it. The child keys are also listed one by one on purpose: if a child were omitted, the Roles editor's auto-expansion of the automations parent would swallow every later key sharing that prefix.

Attendance automations reuse attendance permissions

Deciding attendance is HumanResource's business, so 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 of being allowed to approve attendance — if it did, revoking someone's approval rights in HR would not revoke them here.

Why "publish" and "go live" are two grants

Scheduling a rule that only reports is a genuinely different decision from letting it act, and most operators should have the first without the second. Splitting them means an automation can be built, published and left running in Observe by the team that understands the process, while the decision to let it change payroll inputs stays with whoever is accountable for payroll.

Promotion is refused unless all four of these hold. The checks live in one private method (Automations::_live_refusal()) shared by publish() and set_run_mode(), precisely so the two paths cannot drift apart:

The operator holds automations_go_live. Not automations_publish — that one is already spent getting this far.
The type declares capabilities.live. A type whose handler cannot apply changes must never be promoted, whatever the operator's permissions. The capability is the contributing module's statement that live application is implemented and safe.
The automation has a completed run. An automation that has never finished a run has never been shown to be correct. Requiring the Observe run is what turns going live into a decision rather than a gamble — and an Observe run costs nothing but time.
That run's config_hash matches the current configuration. The evidence has to be about this rule. Otherwise you preview, widen the scope, then promote — and the run that "proved" it was watching a different rule against a different set of records.

Demotion is deliberately asymmetric: dropping Live → Observe needs only automations_update, and it takes effect immediately — including for runs already queued. Stopping is always easier than starting.

Known gap — publish_permissions is declared but read by nothing. The registry contract lets a type name the extra permissions a publisher must hold, and HumanResource declares 'approve' => ['attendance_approval'] for attendance.auto_decision. No code reads that array — not _live_refusal(), not the executor's run-time revalidation.

Exact consequence: an operator holding automations_publish and automations_go_live but not attendance_approval can publish an attendance automation in Live mode, and it will approve attendance records — work that person is not permitted to approve by hand. The run-time check only asks whether the publisher still holds automations_go_live, so the gap persists for the life of the automation, not just at promotion.

Until it is closed: treat automations_go_live as if it implied every action permission the types on this tenant can perform, and grant it only to people who already hold those. Tracked in the implementation plan's known-debt section for Milestone 5 — this is documented deliberate debt, not an oversight to be discovered and "fixed" ad hoc.

Publisher authority

An automation is not an account and holds no permissions of its own. It acts with the authority of the person who promoted it, recorded as published_by. That answers the question every auditor asks first — on whose authority did the system do this? — with a name rather than a service account.

Because people leave, change roles and lose permissions, that authority is revalidated on every tick before a live run is allowed to touch anything. It is not a promotion-time snapshot.

Run claimed
Mode is Live?
Publisher still valid?
Lease + execute

The check fails closed: anything it cannot resolve is a denial. The alternative — "cannot tell, so proceed" — means a deleted user's automation keeps changing records, which is the exact failure this guards against. On denial the automation moves to needs review and the run is recorded as failed with error code PUBLISHER_UNAUTHORIZED; nothing is mutated.

// modules/Automations/libraries/Automation_executor.php
private function publisher_denial($automation)
{
    $publisher_id = (int) $automation->published_by;

    if ($publisher_id < 1) {
        return 'This automation has no recorded publisher, so no authority backs a live run.';
    }

    $this->CI->load->model('Users_model');
    $publisher = $this->CI->Users_model->get_one($publisher_id);

    if (!$publisher || !isset($publisher->id) || !$publisher->id) {
        return "The publisher (user #$publisher_id) no longer exists.";
    }

    if (!empty($publisher->deleted)) {
        return "The publisher (user #$publisher_id) has been deleted.";
    }

    if (isset($publisher->status) && $publisher->status !== 'active') {
        return "The publisher (user #$publisher_id) is no longer active.";
    }

    // user_can() reads the stored permissions for an ARBITRARY user. The similarly
    // named user_has_permit() ignores its $userid argument and evaluates the
    // logged-in user instead — which in a cron process is nobody.
    $this->CI->load->helper('permission');
    if (!function_exists('user_can')) {
        return 'Publisher authority could not be evaluated on this installation.';
    }

    if (!user_can($publisher_id, 'automations_go_live')) {
        return "The publisher (user #$publisher_id) no longer holds permission to run automations live.";
    }

    return null;
}
Never use user_has_permit() here. Despite its signature it ignores the $userid argument and evaluates the logged-in user — who, inside a cron tick, is nobody. user_can($id, $permission) is the function that actually reads an arbitrary user's stored permissions. This trap is called out in the source comment above for exactly this reason.

Observe runs are deliberately not gated

Publisher revalidation applies to Live runs only. An Observe run changes nothing — it reads, decides, and writes its decision to the run log — so there is no authority to borrow. Stopping a report because its author left the company would remove visibility at the precise moment you most need it, and would leave you with an automation you can no longer see the behaviour of. That is a deliberate compromise, and it belongs to whoever owns this module's security posture; do not "harden" it by extending the gate to Observe without replacing the visibility it removes.

The System actor

An unattended approval still has to be attributed to somebody. Attributing it to the publisher would be a lie — they authorised the rule, they did not review the record — and leaving the approver blank breaks the audit trail. So headless writers resolve a dedicated System user, and the trail reads "System Automation".

Resolved by
erpat_system_actor_id() in application/helpers/user_helper.php
Seeded by
application/migrations/20260903232432_seed_system_actor_user.php — a core migration, because the actor is a platform fact, not a module one
The row
user_type='system', status='inactive', disable_login=1, no password — invisible to every staff-scoped query and impossible to log into
Caching
Memoised per database. The id differs per tenant and one cron tick walks several tenants inside a single process, so a bare static would leak the previous tenant's id
When absent
Returns 0 — and 0 must be treated as a refusal, never as a usable actor
A database missing the System actor refuses to run Live. It does not approve with a blank approver. checked_by = 0 joins to nothing: the approver renders empty in the log-details modal, the export and the approval email, and one listing path falls back to naming the record's own creator — silently crediting the approval to the wrong person. The handler checks $gateway->can_attribute() before any live pass and throws instead, naming the migration to run. Failing a run is recoverable; a month of unattributable approvals is not.

If you see that failure, the fix is to run the core migrations on that database — the actor is seeded by 20260903232432_seed_system_actor_user. It is worth checking on any tenant that was provisioned from an older install dump before you promote anything to Live there.

How scheduling actually works

The Automation Center ships no scheduler of its own. ERPat's Advanced Cron runtime already owns the catch-up watermark, the per-tenant fan-out, the per-job lock with stale-steal, the cron_runs log, the timeout sweeper and the health endpoint. The module contributes exactly one job that supplies the two phases that are genuinely its own.

Job slugautomation_dispatch
Schedule* * * * * — every minute
ScoperunsForGlobalScope() === false — per-tenant fan-out. The runtime calls it once per active tenant with the database connection already switched
Time budget45 s per tick, inside a 50 s job timeout — headroom is left for the runtime's own bookkeeping
Claim cap100 occurrences per tenant per tick, so a bad clock cannot flood the queue
Chunk cap20 chunks per run per tick (executor)
First live runcapped at the automation’s first_live_run_limit — builder default 50; the engine constant FIRST_LIVE_RUN_LIMIT (25) applies only when no positive value is stored
Run lease600 s, so a dead worker's run can be reclaimed

Each tick runs two phases. Phase A claims every automation whose pointer has been reached, opens exactly one run per occurrence, and advances the pointer. Phase B hands open runs to the executor, oldest first, until the budget is spent. A run that does not finish keeps its cursor and resumes next tick — unfinished is not failed.

The job self-gates twice before doing anything: it exits if module_automations is not '1' (the cron registry discovers jobs regardless of module state), and it exits quietly if the automations table is absent, which is what happens on the primary-database pass of an install that only migrated the module into tenants.

The 366-day next-occurrence horizon

Next occurrences are computed with App\Cron\Core\CronExpression — the very same parser the runtime's own Scheduler uses, so an automation and a cron job can never disagree about what an expression means. The search steps a cursor minute by minute and stops after 366 days of minutes.

Every expression that can ever match does so within a year. One that cannot — 30 February is the canonical example — is a configuration error, so past the horizon the search returns nothing and the dispatcher clears the pointer and moves the automation to needs review. The same happens to an expression that fails to parse at all. It surfaces as a visible state an operator can act on, instead of an automation that silently never fires again.

Run exactly one cron driver. The CLI driver (cron:tick) locks through FlockLockProvider; the HTTP driver (GET /cron/tick) locks through DbLockProvider and the cron_locks table. The two namespaces do not intersect, so a deployment running both genuinely double-fires the dispatcher. The module survives it — the UNIQUE idempotency key on automation_runs means the second pass creates no second run — but surviving a mistake is not a reason to make it. Pick one driver per environment and put it in the deploy runbook. This is a runtime-level constraint, not something this module can fix from inside.
Nothing scheduled is broken if nothing runs. If no cron driver reaches the dispatcher, automations simply never become due — they do not error, and no state is corrupted. Settings → Cron Manager showing a recent tick for automation_dispatch is the fastest confirmation that scheduling is alive.

The module toggle, and switching a contributing module off

module_automations is seeded off ('0'). The Automation Center is opt-in per tenant: enabling it is a decision somebody makes, not a default that arrives with an upgrade.

Turning it off hides the page, drops the permissions from the Roles editor, and stops the dispatcher on that tenant. It does not delete automations or run history — turn it back on and everything is where you left it, though schedules that came due while it was off were never claimed.

The more interesting case is switching off a module that contributes a type — HumanResource, for the only type registered today:

WhenWhat happens
Registry lookup The type is reported unavailable while module_<owner> is off, so a module's automations cannot outlive its own toggle.
Settings tab The type is still listed, badged inactive rather than vanishing — you can see why an automation stopped.
Execution Availability is re-checked at tick time, not just at claim time, because a module can be switched off between a run opening and its next chunk. The run fails with DEFINITION_UNAVAILABLE and the automation moves to needs review.
Unresolvable toggle If the registry cannot resolve the owning module's setting at all, it reports the type unavailable rather than assuming enabled — fail-safe in the direction of not running.

A malformed type definition is treated differently again: the registry is fail-safe, not fail-closed. A broken sidecar is logged and skipped, never fatal, matching the core config collector that runs on every request. One module's mistake must not take down the Automation Center for the tenant.

Settings tab — engine diagnostics

Gated on automations_settings. It is a read-out, not a control panel — there is nothing here you can misconfigure, which is why it is the right first stop when something is not firing.

Engine
The dispatch job's identity and state, the registry checksum, and the count of registered types — plus a pointer to Cron Manager, where the last tick is recorded.
Registered types
Every type the registry found: key, owning module, version, and available or inactive. An empty table means no module is contributing anything.
Registry notices
Diagnostics for definitions that were skipped or rejected as invalid. If a type you expect is missing entirely, its reason is here.
#
Registry checksum
A fingerprint of the loaded definitions. If it changes when you did not deploy anything, a contributing module's sidecar changed underneath you.

Audit — what gets written down

Seven events are registered in modules/Automations/config/system_logs.php. They are ungated by module state, so historical entries still resolve their labels and filter dropdowns after the module is switched off.

Event keySeverityWritten when
created:automationinfoAn automation is created
updated:automationinfoAn automation is edited
published:automationwarningIt is published and may now run on its schedule
paused:automationwarningIt is paused, or moved to needs review
archived:automationwarningIt is archived
ran:automationinfoA run completes — once per run
settings:automationwarningModule settings are changed

One row per run, never one per record

A 263-record run leaves one audit row, not 263. The aggregate entry carries the automation title and UUID, the type key, the run id, the run mode, the status, the number of records evaluated and the reason-code counts. It is written with an explicit user_id => 0, which renders as "System" — cron has no logged-in user, and an implicit fallback would attribute the run to whoever happened to be around.

The detail lives in two other places, on purpose. The per-record decision trail is automation_run_items, one row per subject with its outcome and reason code. The authoritative business audit stays with the module that owns the data: an attendance approval writes its own attendance log entry through the shared transition service, exactly as the Approve button does. Flooding the system activity log with one row per approved record would drown the trail it is supposed to be.

A failed audit write never fails the run it describes. The write is wrapped and logged on error. That is the right trade — but it means a filesystem or database problem can leave a completed run without its summary row, while the per-item trail survives.

Before you allow Live on a tenant

Confirm the System actor exists. Core migration 20260903232432_seed_system_actor_user must have run on that database. Without it, live runs refuse.
Confirm exactly one cron driver is enabled, and that Cron Manager shows a recent tick for automation_dispatch.
Grant automations_go_live to as few people as possible, and only to people who already hold the action permissions the tenant's types perform — the publish_permissions gap means nothing else enforces that today.
Check the Settings tab: the type you intend to use is listed available, and there are no registry notices.
Require an Observe run against the final configuration before promotion. The system enforces this, but make it a review step too — the point is that a human read the decisions, not that a run row exists.
Agree who watches for needs review. There are no notifications until Milestone 4, so a paused automation is silent until somebody opens the page.

Next: Integration Contract covers what a module declares to contribute an automation type, and Run Lifecycle & Execution follows a single run from claim to completion.

Was this guide helpful?

Report a content problem