Skip to content

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

NamePurposePlaneKey fields
action_typeThe 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_parameterOne input field of the action form.Template + Tenantaction_type, key, type, constraints, values_from, object_type, visibility
action_submission_criteriaA precondition/gate. criteria_type ∈ field / function / reachable / form-substrate.Template + Tenantaction_type, criteria_type, config{field,operator,value,subject,enforcement}, message, order
action_editA declarative field mutation applied post-gate.Template + Tenantaction_type, field, value, function_args, order
action_createA related-entity creation step (sequential, compensating).Template + Tenantaction_type, entity_type, fields, order
action_side_effectDeclares the action emits a named notification_event.Template + Tenantaction_type, event/event_type, when_parameter, when_equals, payload_aliases, order
action_placementProjects the action onto a surface as an affordance.Template + Tenantaction_type, surface, target_model, target_config, key, status, is_active
action_surfaceRegistry of surfaces (audience, accepted_sources, presentation).Vocabulary (system)code, label, audience, accepted_sources, presentation
notification_eventVocabulary of emittable events a side-effect references.Vocabularycode, source_action
ActionDefinition (Pydantic)The runtime compile target — not persisted, compiled per-request.Runtimecode, type, parameters, preconditions, edits, creates, side_effects
edit_eventAudit 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_type in a vertical sheet's actions: 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) and edits._resolve_value, then add the mode to the ValueSourcePicker matrix.
  • New action function: decorate a Python callable @action_function('name') — resolvable as $function.name and pickable in the editor via GET /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_surface registry row; the placement editor's surface picker reads it (no hardcoded enums).
  • New notification: author the action_side_effect (event_type) + a subscribing notification_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 SAME gate criteria subset the submit lane enforces.
  • One criteria vocabulary, two modes: gate rows refuse at submit; classify rows 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_key alone — the caller cannot select surface; audience enforcement runs before config load so 403 wins over 404.
  • Unauth /actions/schema + /_preview gate 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.py steps 7/7b/7c).

Gaps & open edges

  • Doc debt: compile.py:1-15 still describes the JSONB admin_entity_config.actions bridge as the live path — false since #292 S2 (confirmed: the comment reads "The engine reads admin_entity_config.actions JSONB"). loader.py is the real path. Misleads any reader starting from compile.py.
  • Security (dev): _check_action_apply_grant (executor.py:531-605) fails open when the UMS client singleton is None (confirmed at executor.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_siblings caps at limit=500 with no cursor pagination, while loader._list_all does paginate — an action with >500 siblings silently loses rows on one of two divergent fetch paths.
  • Legacy column drift: action_side_effect POSTs must still send type:'notification' because the ontology table requires it though the seed dropped it from field declarations (+page.server.ts:1786-1789); slice 4f pending.
  • /actions/submit is implemented:false (action_schema.py:520): citizen submission actually flows through /public/submit, a separate intake lane (see The Public Intake plane), not execute_action. The "submit" affordance is a placeholder.
  • ActionEdit.value cannot reference $entity.X directlyedits._resolve_value passes entity=None on 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_action only 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, icon are presentation metadata on the execution-plane action_type row — 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.

Atelier — Platform Specification. Internal canonical reference.