The Action Engine — the execution plane
The action engine is Atelier's execution plane: the bridge between what exists (the Data plane — entities, properties, states) and what is experienced (the Surface plane — widgets, layouts, bindings). Where the data plane answers "what is true," the action engine answers "what can happen, to whom, under what conditions, and with what consequences." An operator never writes Python to add a behaviour. They author an action_type ontology row plus six child row-classes — inputs, gates, mutations, creates, side-effects, placements — and the BFF compiles those rows at request time into a Pydantic ActionDefinition that a single uniform pipeline validates, gates, mutates, audits, and fans out. This is the compound-software thesis made concrete for behaviour: any action projects onto any surface as a button or form (its placement), and the catalog of actions grows by adding rows, never by editing the engine.
Data model
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
action_type | The action unit; compiles to ActionDefinition. Carries kind (detail/bulk/async), target_model, permissions_policy (view/apply/edit), presentation metadata. | Template + Tenant (TYPE in Vocabulary) | key, target_model, kind, application, organization, permissions_policy, status |
action_parameter | One input field of the action form. | Template + Tenant | action_type, key, type, constraints, values_from, object_type, visibility |
action_submission_criteria | A precondition/gate. criteria_type ∈ field / function / reachable / form-substrate. | Template + Tenant | action_type, criteria_type, config{field,operator,value,subject,enforcement}, message, order |
action_edit | A declarative field mutation applied post-gate. | Template + Tenant | action_type, field, value, function_args, order |
action_create | A related-entity creation step (sequential, compensating). | Template + Tenant | action_type, entity_type, fields, order |
action_side_effect | Declares the action emits a named notification_event. | Template + Tenant | action_type, event/event_type, when_parameter, when_equals, payload_aliases, order |
action_placement | Projects the action onto a surface as an affordance. | Template + Tenant | action_type, surface, target_model, target_config, key, status, is_active |
action_surface | Registry of surfaces (audience, accepted_sources, presentation). | Vocabulary (system) | code, label, audience, accepted_sources, presentation |
notification_event | Vocabulary of emittable events a side-effect references. | Vocabulary | code, source_action |
ActionDefinition (Pydantic) | The runtime compile target — not persisted, compiled per-request. | Runtime | code, type, parameters, preconditions, edits, creates, side_effects |
edit_event | Audit row written after each run; feeds the editor Activity tab. | Runtime (ontology entity, #248) | entity_type, entity_id, action_code, actor_id, payload, changes |
The compile target and its field-level DSL docs live in admin-api/app/models/admin_config.py:22-266. The pure compiler compile_action_definition_from_rows (admin-api/app/actions/compile.py:477-521) sorts every sibling list by order ascending, drops is_active:false rows, and silently diverts three criteria classes: form-substrate (required_fields/one_of/range), reachable (compiled separately for the caller-rooted submission gate), and role-access field criteria (enforced via UMS apply rules, not engine preconditions, compile.py:131-148). Anything unrecognized is warned-then-dropped — a deliberate, logged "saved-but-never-enforced" trap rather than a silent accept.
How it's declared
Two authoring surfaces, one row schema. The visual editor is the admin SvelteKit route /settings/actions (inventory) → /settings/actions/[id] (admin/src/routes/settings/actions/[id]/+page.svelte, a 5572-line editor) with DS tabs: Purpose (action_type metadata, permissions_policy, icon, activity_template), Inputs (action_parameter, "Add from schema" prefill, drag-reorder), Rules (action_submission_criteria, plus the editor-only role_access virtual type that compiles to field criteria), Effects (action_edit + action_create + action_side_effect; the side-effect modal picks a notification_event), Placements (action_placement with a Combobox scope picker reading the live target_model schema), and Activity. Every "literal-or-reference" slot uses one ValueSourcePicker DS component expressing the engine's value modes (literal / $parameters / $entity / $user / $now / $source.id / $created.N.F / $config.T / $function / $expr), narrowed per-consumer. Each tab posts to a server action (admin/src/routes/settings/actions/[id]/+page.server.ts:535-1874) that PATCHes/POSTs the corresponding ontology entity through the BFF entity proxy. The second surface is the sheet: an actions: block with nested parameters/creates/edits/submission_criteria/side_effects/placements keys (admin-api/app/applications/yaml_import.py:354-358; worked example in admin-api/provisioning/sheets/sheet_b1_traffic.yaml). There is no third surface — the JSONB cache is dead (loader.py:1-28), so editing rows is editing the runtime contract.
How it's provisioned
The Vocabulary plane (system) registers the action_type entity TYPE plus the action_surface and notification_event registries — not forked. The Template plane (template_municipality) holds the forkable worked example: action_type rows + all six child classes + placements + permissions_policy, authored via the actions: sheet block and imported by yaml_import.py, which bridges the sheet grammar to ontology child rows (parameters→action_parameter, … placements→action_placement) and drops the side_effect YAML type discriminator. A one-shot full-catalog fork copies and FK-rewires all these row classes onto a new Tenant plane (e.g. lisbon); the loader pins X-Tenant-Id to the caller's resolved tenant, so a tenant without rows gets None ("no actions authored here yet"). After fork, a tenant admin edits THEIR copy with the SAME editor and zero special-case code. Cross-plane authoring rides the single X-Author-Tenant header, honored only for callers holding the right UMS grant. On every save, the editor's write hooks (admin-api/app/actions/write_hooks.py:89-335) flush the loader's per-process 30s TTL cache and resync UMS apply rules from permissions_policy.apply + role-access criteria, fanned per-app (#409 Phase 4).
Extension points (the growth vector)
- New action: declare an
action_typein a vertical sheet'sactions:block (or "New operation" in the editor), nest the six child keys. No code. - New value-source / DSL ref: extend
resolve_ref(admin-api/app/actions/refs.py:17-75) andedits._resolve_value, then add the mode to theValueSourcePickermatrix. - New action function: decorate a Python callable
@action_function('name')— resolvable as$function.nameand pickable in the editor viaGET /functions?category=action_function. - New precondition operator: extend the operator set in
validation.py(eq/ne/in/gt/lt/within/age_between/is_null…). - New surface: insert an
action_surfaceregistry row; the placement editor's surface picker reads it (no hardcoded enums). - New notification: author the
action_side_effect(event_type) + a subscribingnotification_rule— the 2-step operator path, no code fan-in (see The Notification Subsystem). - Durable workflow: set
action_type.kind=async; the engine emits a CloudEvent to Martha which runs the workflow and calls back to apply edits (see Martha durable backend).
Invariants
- The rows ARE the runtime contract — no derived JSONB cache;
load_action_definitions(admin-api/app/actions/loader.py:97-216) compiles per-request. - Editor and submit never disagree on gating:
compute_available_actions(admin-api/app/actions/availability.py:61-160) evaluates the SAMEgatecriteria subset the submit lane enforces. - One criteria vocabulary, two modes:
gaterows refuse at submit;classifyrows are skipped by gates and evaluated post-create. - Role-access intent lives in UMS apply rules, never engine preconditions (avoids double-enforcement).
- Apply rules constrain on the QUALIFIED key
{target_model}:{key}(executor.py:588-599), so same-named actions across entities never merge role sets. - Submit surface is derived from
placement_keyalone — the caller cannot select surface; audience enforcement runs before config load so 403 wins over 404. - Unauth
/actions/schema+/_previewgate on placement published+active+citizen-facing; off-gate returns 404, not 403 (no existence/state leak;action_schema.py:411-426). - No-op edit gate: an empty resolved diff skips side-effect dispatch (
executor.py:424-436) — the diff is the only exactly-once boundary for re-asserting cron lanes. - Side-effects / CloudEvent / audit are best-effort and never fail the action (
executor.pysteps 7/7b/7c).
Gaps & open edges
- Doc debt:
compile.py:1-15still describes the JSONBadmin_entity_config.actionsbridge as the live path — false since #292 S2 (confirmed: the comment reads "The engine readsadmin_entity_config.actionsJSONB").loader.pyis the real path. Misleads any reader starting fromcompile.py. - Security (dev):
_check_action_apply_grant(executor.py:531-605) fails open when the UMS client singleton isNone(confirmed atexecutor.py:563-571) — an operator with no apply grant runs unenforced. The code itself says "once that's no longer the case, flip this to deny." Real gap on any stack without UMS. - Pagination ceiling:
compile._fetch_siblingscaps atlimit=500with no cursor pagination, whileloader._list_alldoes paginate — an action with >500 siblings silently loses rows on one of two divergent fetch paths. - Legacy column drift:
action_side_effectPOSTs must still sendtype:'notification'because the ontology table requires it though the seed dropped it from field declarations (+page.server.ts:1786-1789); slice 4f pending. /actions/submitisimplemented:false(action_schema.py:520): citizen submission actually flows through/public/submit, a separate intake lane (see The Public Intake plane), notexecute_action. The "submit" affordance is a placeholder.ActionEdit.valuecannot reference$entity.Xdirectly —edits._resolve_valuepassesentity=Noneon the value side, so operators must round-trip an entity field through a$function. A documented authoring sharp edge.- Async callback half-visible:
_execute_async_actiononly emits the CloudEvent; the Martha→BFF callback that applies edits lives in Martha workflow definitions, so the durable round-trip can't be verified from the action-engine code alone. - Placement-key ambiguity: when two apps share a placement key under one
target_model, key-only lookup 422s and requires?application=. The "safe while no two apps share a key" assumption is load-bearing and enforced only by convention. - Surface bleed:
activity_template,color,iconare presentation metadata on the execution-planeaction_typerow — a minor violation of the M10 three-plane separation. - Non-transactional editor writes: reorder/multi-row edits are PATCH-per-row (207 on partial failure), so a half-applied reorder or partial "add from schema" leaves some rows landed and the rest dropped, by design.