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.
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. |
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:
automations_go_live. Not
automations_publish — that one is already spent getting this far.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.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.
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.
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;
}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()inapplication/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— and0must be treated as a refusal, never as a usable actor
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 slug | automation_dispatch |
| Schedule | * * * * * — every minute |
| Scope | runsForGlobalScope() === false — per-tenant fan-out. The runtime calls it once per active tenant with the database connection already switched |
| Time budget | 45 s per tick, inside a 50 s job timeout — headroom is left for the runtime's own bookkeeping |
| Claim cap | 100 occurrences per tenant per tick, so a bad clock cannot flood the queue |
| Chunk cap | 20 chunks per run per tick (executor) |
| First live run | capped 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 lease | 600 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.
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.
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:
| When | What 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.
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 key | Severity | Written when |
|---|---|---|
created:automation | info | An automation is created |
updated:automation | info | An automation is edited |
published:automation | warning | It is published and may now run on its schedule |
paused:automation | warning | It is paused, or moved to needs review |
archived:automation | warning | It is archived |
ran:automation | info | A run completes — once per run |
settings:automation | warning | Module 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.
Before you allow Live on a tenant
20260903232432_seed_system_actor_user must have run on that database.
Without it, live runs refuse.automation_dispatch.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.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.