Research & Known Gaps
How we know what this guide claims, what is measurably true today, and — in the same amount of detail — what is not true yet. A reader who trusts the rest of this guide is entitled to know exactly where its ground stops.
How this guide was verified
Every behavioural claim in this guide was read out of the working tree, not recalled from a specification. Where the specification and the code disagreed, the code won and the specification was corrected — that happened at least four times (see Decisions). The files below are the primary sources.
The module itself
| Path | What it settles |
|---|---|
config/automations.php | The 71-line sidecar contract docblock — the only normative description of what a type may declare. Its own array is empty by design: the module registers no types. |
libraries/Automation_registry.php | Discovery, path containment, checksum composition, the is_available() gate. |
libraries/Automation_executor.php | MAX_CHUNKS_PER_TICK 20, FIRST_LIVE_RUN_LIMIT 25, the 600-second lease, publisher revalidation, the run-level failure codes. |
libraries/Automation_rule_engine.php | The operator vocabulary and evaluation order. |
libraries/contracts/*.php | Both interfaces — and the fact that their @param tags say object $context, not a class (see Phantom APIs). |
jobs/AutomationDispatchJob.php | automation_dispatch, * * * * *, runsForGlobalScope() === false, a 45-second budget, 100 claims per tick, Phase A / Phase B split. |
controllers/Automations.php | The gates, _live_refusal()'s four preconditions, and the two hardcoded version columns in save(). |
helpers/automations_helper.php | automations_reason_codes(), scope-token normalisation, config hashing. |
config/{permissions,routes,menu,default_menu,system_logs}.php | The ten frozen permission keys, the Administration placement, the registered log events. |
migrations/*.php (3) | The three tenant tables, the toggle split, and automation_runs.run_mode. |
views/automations/** | The five lazy tabs, the wizard, the run-detail modal — and the builder's hardcoded Action/Safety panels. |
Outside the module
| Path | What it settles |
|---|---|
modules/HumanResource/config/automations.php | The only registered type today, attendance.auto_decision — and every declared-but-unread key on this page came from reading it against its consumers. |
modules/HumanResource/automation/Attendance_auto_decision_handler.php | The real reason codes, the overtime policy, the eligibility rules, and the deliberate re-implementation named in debt item 20.1. |
modules/HumanResource/automation/Attendance_condition_provider.php | The condition catalogue the wizard offers. |
modules/HumanResource/automation/Attendance_transition_gateway.php | The headless host shim — debt items 20.9 and 20.10. |
application/migrations/20260903232432_seed_system_actor_user.php | The System actor, and the id-0 poisoning guard added after a real tenant produced one. |
modules/Automations/specs/automation-center-implementation-plan.md | 20 sections. §14 (reason-code drift) and §20 (known debt) are the sources for two whole sections below. |
modules/Automations/specs/automation-center-status.md | The milestone evidence tables reproduced under Milestone evidence. |
.claude/rules/automation-center.md | The agent-facing invariants, kept in step with the above. |
Milestone evidence
| # | Milestone | Status | Version |
|---|---|---|---|
| M1 | Foundation — scaffold, migrations, toggle split, registry, dispatch job, models | Done 2026-09-03 | Automations 0.1.x |
| M2 | Builder + Observe mode | Done 2026-09-03 | 0.2.0 / HR 1.21.0 |
| M3 | Live decisions, safeguards, clone | Done 2026-09-03 | 0.3.0 / HR 1.23.0 |
| M4 | Operations UI, analytics, notifications, both API surfaces | Pending | — |
| M5 | Hardening — docs, OG image, behavioural tests, pilot rollout | Pending | — |
M1 — the dispatcher does not double-fire
Run against the dev primary database dev_app with a throwaway,
self-cleaning harness. The point of M1 was never "it runs" — it was
idempotency, because a scheduler that opens two runs for one occurrence would
later approve the same record twice.
Tick 1 opened exactly one run, advanced next_run_at_utc, wrote the 64-char
idempotency key, snapshotted the configuration and stamped a correlation id.
Re-presenting the same occurrence on tick 2 created no second run. An unregistered
type closed its run failed / DEFINITION_UNAVAILABLE and moved the automation
to needs_review rather than retrying forever. Every test row was removed and
the module toggle restored.
M2 — the inertness proof
Observe mode's entire value is that it changes nothing. "We looked and it seemed fine"
is not evidence of that, so the M2 run on tenant dev_c_rs8 fingerprinted the
database before and after:
| Measured before and after a full Observe run | Result |
|---|---|
| Attendance row count | byte-identical |
CRC fingerprint over status / checked_by / checked_at | byte-identical |
| Pending count | byte-identical |
attendance_metrics row count | byte-identical |
Alongside that: 356 candidates evaluated, matching an independent SQL count
exactly; every item carried an outcome and a reason code; the counts reconciled
(would + skipped = candidates); Observe results landed in
would_count and never in approved_count; exactly one audit row
was written for the run. Test suites: 41 module / 230 HumanResource / 955 core. A
browser pass over the UI scored 27/27 — one fragment fetched on load, unvisited
tabs empty, tab switching not navigating, re-selection not refetching, both server-side
DataTables initialising, no stuck-light panel in dark mode, zero JS errors.
implements at compile time, so that was an
immediate fatal, not a subtle later miss. And a single broken type took down the
whole tick instead of just its own run.
M3 — the cap held, and the rollback was clean
The Live run was driven through the real cron CLI against real attendance on
tenant dev_c_rs8. The headline number is the one to internalise:
Every one of the 25 came out status=approved, with checked_by
set to the System actor, checked_at stamped, and carrying the
attendance_metrics row payroll reads. Each per-record audit entry was
remarked automation; the run recorded itself run_mode=live.
No record outside the approved 25 changed. Rollback restored all 120 rows
byte-identical, leaving zero system-actor approvals, zero leftover automations, zero
orphan metrics rows and zero leftover audit rows. Clone was verified separately at
15/15 in the browser and 28/28 in the database, against a source deliberately left
published, live, scheduled and on revision 7 with run pointers — every reset checked, the
source itself unmodified.
The System actor was seeded on three databases and confirmed
user_type=system, status=inactive,
disable_login=1, and invisible to every staff-scoped query. Suites at M3:
968 core / 255 HumanResource / 61 Automations.
users.id had lost its PRIMARY KEY and AUTO_INCREMENT (pre-existing drift),
so the actor insert produced id 0 — precisely the blank-approver state the actor
exists to prevent — and the migration's naive "a row exists" check would have skipped
past it forever. __get forwards properties, not method calls, so
var_biometrics_option() had to be declared explicitly on the gateway. The
handler read $context->title, which does not exist (the executor sets
automation_title) — the approval would still have happened and only the
audit remark would have been blank, which is noticed much later or never. And
the executor never revalidated the publisher although permissions.php
documented that it did.
What is not shipped
Milestones 4 and 5 are pending in full. Nothing below exists in the working tree; do not design against it, and do not report its absence as a bug.
| Workstream | Not shipped | What exists today instead |
|---|---|---|
| M4.1 Overview | Dispatcher health strip (last heartbeat, due backlog, next scan, timezone) and the Needs Attention list. | Status counts, the five most recent runs, and the registered type list. |
| M4.2 Analytics | Automation_runs_model::get_analytics_totals(), the run-reliability vs record-outcome split, filters, the per-automation table. |
tabs/analytics.php sums the last 200 run rows in a PHP foreach — debt item 20.6. |
| M4.3 Settings | An editable form for module-owned defaults, server-side range validation, the sticky save bar, and anything emitting settings:automation. |
A read-only diagnostics tab — while automations_settings and the registered settings:automation log event both already exist. That incoherence is acknowledged, not accidental. |
| M4.4 Notifications | config/notifications.php entirely — no automation_run_failed, no automation_needs_review, no automation_publisher_revoked. |
Nothing. A failed run is visible in the Runs tab and nowhere else. |
| M4.5 APIs | api_routes.php, Automations_api, api_scope.php (automations:read), euapi_routes.php, Automations_euapi, regenerated OpenAPI, AutomationsApiTest. |
Nothing. There is no API surface at all — neither integration nor end-user. |
| M4.6 States | The designed empty / engine / run / permission / conflict state catalogue. | Ad-hoc states per view. |
| M5 Hardening | The OG image public/assets/images/modules/automations-og.png (referenced by metadata.og_image, file does not exist); behavioural tests to complement the source-text ones; the dark-mode and a11y pass; the staged pilot rollout. |
Source-text contract tests, and this guide. |
Two deliberate deferrals, distinct from "pending": the dashboard widget pair
(config/widgets.php + helpers/automations_widget_helper.php) is
a natural MINOR follow-up, and the automation_daily_metrics rollup table will
be built only on a measured need — deriving from run aggregates satisfies the
single-source-of-truth rule until a measurement says otherwise.
Phantom APIs — declared, documented, and read by nothing
This section is the one most likely to save you a wasted afternoon. Each item below looks like a supported extension point. None of them is wired to anything.
There is no Automation_context class
The handler interface's docblocks say @param object $context Automation_context.
That name refers to no class anywhere in the tree. The first specification draft declared
typed parameters — preview(Automation_context $context, …) — and
following it produced a fatal, because the executor passes a plain
stdClass-style object it assembles itself. The type hint was removed; the
docblock name survives as a label for the shape.
// modules/Automations/libraries/contracts/Automation_handler_interface.php
/**
* @param object $context Automation_context <-- a NAME, not a class
*/
public function preview($context, array $config);Read the properties the executor actually sets before you use one. The M3 verification
caught a handler reading $context->title, which does not exist — the
executor sets automation_title. Nothing errored; the audit remark would
simply have been blank forever.
Nothing reads publish_permissions
The HumanResource sidecar declares it, and its comment describes exactly the behaviour a reader would want:
// modules/HumanResource/config/automations.php
// Extra permissions the PUBLISHER must hold to publish a LIVE automation of this
// type; automations_publish is always required on top. Revalidated at run time,
// so a lapsed permission pauses the automation rather than continuing on the
// publisher's behalf.
'publish_permissions' => array(
'approve' => array('attendance_approval'),
'reject' => array('attendance_approval', 'attendance_update'),
),A repository-wide grep finds the key in exactly two files: the sidecar that declares it and the contract docblock that documents it. No consumer. The consequence is concrete and worth stating plainly:
automations_publish and
automations_go_live but not attendance_approval can
today publish a LIVE attendance automation. The four _live_refusal()
preconditions still apply and the run-time publisher revalidation still runs — but it
revalidates automations_go_live only, never the type's declared extra
permissions. Treat the type-level permission as documentation of intent, and gate
access with the module permissions until this is wired.
Nothing applies defaults
defaults is the richest, best-commented block in the HumanResource sidecar —
action, run_mode, overlap_policy,
lookback_days: 7, settle_hours: 12,
overtime_policy: skip, each with the measurement or the reasoning behind it.
It is read by nothing. The same defaults are re-hardcoded in the builder view
(views/automations/builder_modal.php writes 7 and
12 as literal value attributes and 'skip' as the
dropdown's selected option). The numbers agree today; nothing keeps them agreeing.
Four of five capabilities keys are unread
| Key | Read by | Effect |
|---|---|---|
live | Automations::_live_refusal() | Enforced — precondition 2 of 4. |
preview | nothing | Decorative |
dry_run | nothing | Decorative |
chunked | nothing | Decorative |
manual_run | nothing | Decorative |
Declaring 'preview' => false will not stop the builder previewing;
declaring 'chunked' => false will not stop the executor chunking. The
capability contract is one key wide.
The two version columns are hardcoded on save
// modules/Automations/controllers/Automations.php (save)
"definition_version" => 1,
"config_schema_version" => 1,Both columns exist to detect that a stored automation was authored against an older
definition. The registry does read the sidecar's own
definition_version / config_schema_version when composing its
checksum — but the automation row is stamped 1 unconditionally, so a
comparison between the two would be meaningless. Clone copies the source row's values
faithfully, which is correct and also inherits the 1. Do not build a
migration path on these columns until save() reads them from the
definition.
The reason-code vocabulary validates nothing, and has drifted
automations_reason_codes() in the module helper returns 17 codes. Reason
codes actually arrive from three sources, and the declared list matches none of
them cleanly, because record_item() validates outcome but writes
reason_code verbatim.
| Source | Codes it really emits |
|---|---|
| The handler's baseline rules | ELIGIBLE · PAYROLL_LOCKED · SCHEDULE_CHANGE_PENDING · NOT_YET_SETTLED · OVERTIME_PRESENT · RULE_NOT_MET · APPROVED · TRANSITION_ERROR |
Passed through from the transition service, tagged refused_by = 'transition_service' |
NOT_FOUND · PERMISSION_DENIED · PAYROLL_LOCKED · ATTENDANCE_WINDOW_LOCKED · OVERLAP_CONFLICT · SAVE_FAILED · OK |
| The executor, for run-level failures | DEFINITION_UNAVAILABLE · CONFIG_NEEDS_REVIEW · PUBLISHER_UNAUTHORIZED · EXECUTOR_ERROR · AUTOMATION_MISSING · HANDLER_ERROR · HANDLER_CONSTRUCT_FAILED |
Five codes the handler emits every run — APPROVED,
NOT_YET_SETTLED, OVERTIME_PRESENT, RULE_NOT_MET,
TRANSITION_ERROR — are absent from the declared list. Nine declared
codes are emitted by nobody: MISSING_TIME_IN,
MISSING_TIME_OUT, OVERLAPPING_ATTENDANCE,
ATTENDANCE_ANOMALY, LEAVE_CONFLICT, OUTSIDE_SCOPE,
STATUS_CHANGED, ALREADY_SATISFIED,
MUTATION_VALIDATION_FAILED. The list has exactly one consumer — a test
asserting the vocabulary reflects features that exist. Harmless at runtime today;
it will produce wrong labels the moment any UI renders from it. That is debt item
20.8.
The builder is hardcoded to one type
The wizard's Action and Safety panels render lookback_days,
settle_hours, overlap_policy and overtime_policy
as literal fields with literal defaults and literal bounds
(min="1" max="365", min="0" max="720"). Those are the attendance
type's fields. The rest of the module is registry-driven; this view is not. A second
registered type needs these panels made registry-driven first — otherwise it either
inherits attendance's configuration shape or gets no configuration UI at all.
The known-debt register
Plan §20. Each item is verified, each has an owner, and each is a deliberate compromise — not an oversight. Fixing one without reading its rationale is how a safe system becomes an unsafe one.
Attendance_auto_decision_handler::preview() re-implements the
payroll-lock, rolling-window and overlap rules instead of calling the service that
owns them — because those checks are interleaved with the mutation inside
transition_attendance_status() and cannot be invoked alone.
The handler documents this as deliberate, and the service's refusal correctly overrides the handler's verdict, so the system is safe. But preview and live can disagree about a record right up to the moment of the write.
Named fix: extract
evaluate_attendance_transition($log_id, $status, $opts): array from
those five pure read blocks (existence + scope, permissions, payroll lock, rolling
window, overlap) and have transition_attendance_status() call it.
Behaviour-preserving, and it gives preview true parity.
AttendancesTrait.php:418-423 — $instance->status !== "pending"
is false for every real approval, and the sibling identity check strict-compares a
DB string to a session int, so it is always true. Preserved verbatim through the
extraction, because fixing it would start denying approvals that work
today, and a refactor whose criterion is "nothing changed" must not also change
who may approve. The automation does not depend on this gate — publisher authority
is its barrier. Raise as its own change with its own regression budget.
access_type / allowed_members are dead inputs on this path HumanResource ›Attendance_model::get_details() guards row-scoping with
if (!$id && …), and transition_attendance_status()
always passes an id. Both update_status() and
bulk_update_attendance() pass scope options that have no filtering
effect. The permission gate is the only barrier — and per 20.2 it is inert.
This is precisely why the automation resolves its scope itself, up front, and
why publisher authority is revalidated at run time.
TeamAttendance_euapi::update_status() has not converged Deliberate ›Own save, own metrics recompute, own lock check, no overlap check. Deliberate: it
extends EndUserApiAuthController, and the trait's metrics path reaches
an App_Controller helper — forcing the trait in would violate the
API-controller rules. Its worst gap, the missing
set_system_logs(), is fixed, tagged
remarks: api:<status>. Full convergence needs the 20.1 extraction
first, since a pure predicate is callable from an API controller in a way the
mutating trait is not.
FlockLockProvider (CLI) and DbLockProvider (HTTP) use
separate namespaces, so running both drivers can double-fire the
dispatcher. Contained by the UNIQUE idempotency_key — which is why
M1's duplicate-run test matters — but the operational rule is one driver
only, and it belongs in the deploy runbook. This is runtime-level, not
module-level.
tabs/analytics.php sums the last 200 run rows in a
foreach. Unbounded as history grows, and counting belongs in SQL.
The named fix is Automation_runs_model::get_analytics_totals(), listed
under What is not shipped.
user_ids === array() emits AND 1=2 Not a bug — pin it ›In both get_auto_decision_candidates() and its counter, deliberately:
an explicitly empty configured scope means "nobody", not "everybody", and
scope_user_ids() returns array() rather than
null to say so. The failure mode this prevents is an automation scoped
to a since-emptied department silently acting on the entire company.
Pin it with a test so nobody "simplifies" it into an unscoped query.
automations_reason_codes() has drifted and validates nothing Automations ›Detail under Phantom APIs. There are two ways
to close it and they are not equivalent. Make the list descriptive —
regenerate it from the three real sources and keep the test as the pin. Or make it
normative — have record_item() validate
reason_code the way it already validates outcome.
Normative is stricter and would have caught this, but it converts an unknown code
from a harmless service passthrough into a write failure, so the list must
be complete first. Descriptive in M4; consider normative in M5.
Attendance_transition_gateway does not declare
with_permission(), and __get forwards properties, not
method calls — so a reachable call is a fatal Call to undefined method. Its
docblock claimed the opposite ("returns FALSE here, always") until 2026-09-04; the
claim was never true. Three separate options keep every trait call site
unreachable: check_permissions => false,
metrics_override => false, ot_permission => true.
The decision, owned by HumanResource: declaring the method removes the
trip-wire, but the safe return value is not obvious. false denies at
two call sites and skips at a third — both fine — yet at the overtime site it would
silently queue every record's overtime as pending instead of failing loudly,
which is a quiet pay-withholding change. Throwing is arguably more consistent with
this class's own "refuse rather than approve unattributed" stance.
Not decided.
AttendancesTrait, shimmed differently HumanResource ›Attendance_transition_gateway (cron) and
checkfix/AttendanceMetricsCheck (web console) solve the same problem
differently. The former aliases the trait constructor away, forwards
__get to get_instance(), delegates
var_biometrics_option() to erpat_biometrics_option(), and
declares no with_permission(). The latter overrides the constructor
plainly, forwards __get to its host controller, delegates
var_biometrics_option() to that host (and throws under CLI), and
declares with_permission() returning true.
Neither is wrong for its own caller, but a third host must extract the shim
rather than copy it a third time — copy drift across hosts is exactly what the
transition-service extraction was undertaken to end. Note also that
approve_attendance() has four call sites, not one.
Decisions you might otherwise try to reverse
Each of these looks, at first glance, like something that should have been done the other way. Each was driven by a verified repository fact, so reversing one means re-checking that fact first.
One cron job, not a custom dispatcher
The original external draft specified a cron registration file, a distributed lock, a
heartbeat table, a provider due-index and a catch-up policy. ERPat's Advanced Cron
runtime already provides every one of them. The module registers a single
JobContract class and nothing else. Both provider-level tables were dropped
for the same reason: Tick already fans out per tenant with the database
switched, so the job reads the current tenant's own table, and liveness is
/cron/health plus cron_runs. Per-automation schedules reuse
App\Cron\Core\CronExpression — it is ERPat's single parse authority, and a
second parser would drift.
Publisher authority, not a service principal
The draft wanted a "virtual service principal". ERPat has no service-principal
primitive, and inventing one is not a module's job. What is buildable today is
publish-time and run-time revalidation of the publisher's own permissions, with the
audit actor being the seeded System user. This has a property a service principal would
not: authority fails closed — a publisher who is deleted, deactivated or stripped
of automations_go_live moves the automation to needs_review and
fails the run PUBLISHER_UNAUTHORIZED, rather than continuing to approve
records on the authority of someone who has left.
user_has_permit() here. It ignores its $userid argument
entirely and answers for the session user, which on a cron tick is nobody.
Plural, slug-keyed permission names
The plan first specified singular keys; the shipped keys are plural
(automations, automations_create, …). The
make:module default and the majority ERPat precedent both key permissions to
the module slug (Todo→todo, Notes→note; Assets→asset
is the outlier), and keeping the base equal to the slug removes a singular/plural split
between with_module() and with_permission().
users.permissions and roles.permissions by name.
Renaming one now is a data migration, not a rename. The same fact closed the open
question about the module's name: "Automation Center" is settled because the slug, the
module_automations setting and all ten keys are already out.
Administration placement, and its three core-side companions
The plan originally put the module in the Productivity group. It ships in Administration, between System Logs and Settings, with category "System" — because the Automation Center configures how ERPat acts on its own records. That is governance, not a productivity app, and it reads naturally beside the other two system surfaces.
The move required three core-side companions, and a future reader relocating this item needs all three:
Left_menu::_group_administration_items() — the member order.Navigation_registry::_administration_item_names() — the Left Menu V2 registry's matching list.application/config/left_menu.php — the core administration slice had to be split so settings keeps its own slice at position 999 and stays last.The item name was unchanged by the move, so no saved custom menu was orphaned and no remap migration was needed. Three contract tests pin the placement across the module config and both core lists.
Approve-only, and the scaffolding that stayed
Phase 1 ships no Reject action.
Attendance_auto_decision_handler::validate_config() refuses any other action
with automation_error_action_not_supported. The reasoning: an Observe run can
prove a rule selects the right records, but it cannot prove a rejection is
correct — and a wrong rejection withholds earned pay invisibly until payday.
The forward-compatible scaffolding is deliberate and should not be tidied away:
rejected_count and would_reject on the run tables,
publish_permissions['reject'] in the sidecar, and the paused demo-seeder
row.
The overtime policy destroys nothing
Worth restating here because it is the decision most likely to be misread as a data
bug. The measured overtime hours are always stored. The policy records a
decision about them in attendance_metrics.ot_status, and unapproved
hours are withheld at read time. So skip (the default) decides nothing
and queues the record for a person; exclude approves base hours and rejects
the overtime; include approves both. No policy erases a number, and every
choice is reversible from the Overtime tab.
Idempotency without a revision column
The draft's staleness guard assumed an attendance revision number. The
attendance table has no revision, created_at or
updated_at column, so the guard was reformulated: a transactional
status re-read immediately before the write, plus
UNIQUE(run_id, subject_type, subject_id) for per-run dedupe. Similarly, the
draft's "pending attendance correction request" rule was dropped outright — that
feature does not exist in ERPat — and replaced with "no pending schedule change
request", which does.
Re-verifying this page
This page ages the moment M4 starts. Before trusting it against an unfamiliar checkout, re-run the three cheap checks that would catch most drift:
# 1. Is the phantom still phantom? Each of these should return the DECLARING
# file and the docblock only — never a consumer.
grep -rn "publish_permissions" --include=*.php modules/
grep -rn "Automation_context" --include=*.php modules/
# 2. Have the reason codes converged? Compare the declared vocabulary
# against what the handler and the executor actually emit.
grep -n "automations_reason_codes" -A 25 modules/Automations/helpers/automations_helper.php
grep -oP "'[A-Z_]{4,}'" modules/HumanResource/automation/Attendance_auto_decision_handler.php | sort -u
# 3. Has M4 landed? Any of these existing means this page is out of date.
ls modules/Automations/config/notifications.php \
modules/Automations/config/api_routes.php \
modules/Automations/config/api_scope.php \
modules/Automations/controllers/Automations_api.php 2>/dev/nullThen re-read modules/Automations/specs/automation-center-status.md §1 for
the milestone table and §20 of the implementation plan for the debt register. Both are
maintained as work lands; this page is a reading of them at a point in time.
The one question still open
Three of the four open questions closed during implementation: the module name stays "Automation Center", the transition-service extraction was accepted and owned by HumanResource, and Phase 1 is approve-only. One remains: which tenant and which department host the observe-versus-human comparison pilot. Owner: Operations. Needed by M5, and it is the last thing between the module and a staged rollout.