Skip to content

Available Action Descriptors Specification

Status

Implementation-ready contract for MS-02 Phase 3. This spec defines the server/Rust-WASM-owned action availability output that process-detail pages may render into ActionDialog launch buttons.

It extends the Phase 1/2 universal action dialog work in docs/architecture/universal-action-dialog-spec.md.

Goal

Expose a deterministic, UI-safe list of business actions that are available for a process, without making TypeScript evaluate workflow semantics.

A process-detail UI should be able to:

  1. fetch a process flow/projection from the server,
  2. render available action descriptors as buttons/menu items,
  3. pass one descriptor directly into ActionDialog, and
  4. submit the resulting signed event only through process_event.

Non-goals

  • Do not evaluate roles, guards, topology, branch conditions, terminal state, or action availability in TypeScript.
  • Do not hardcode process-detail business action lists in UI.
  • Do not introduce legacy data or action.data.*.
  • Do not move signing/canonicalization/hash construction out of the shared EventBuilder path.
  • Do not make descriptors a security boundary. process_event remains authoritative and must re-check the workflow at mutation time.

Current state

Current server/Rust surface:

  • pg-governed-events/src/flow.rs implements get_process_flow(actor_roles, process_id).
  • The response includes available_actions, but its current shape is legacy UI-oriented:
  • name
  • role_required
  • when_condition
  • then_condition
  • action_allowed
  • mutate
  • required_inputs
  • Current required_inputs are inferred from effect expressions and are not the canonical ActionInputSchema used by ActionDialog.
  • Current get_process_flow loads process data and workflow definitions server-side and already owns role/topology/guard projection.

Current frontend surface:

  • ActionDialog accepts:
  • workflowPath
  • actionName
  • processId
  • currentState?
  • prevHash?
  • actionDefinition?
  • inputSchema?
  • ActionDialog renders input metadata and calls prepareAction(...) + submitEvent(...).
  • ActionDialog must not decide whether an action is available.

Contract overview

get_process_flow should expose a new descriptor array named available_action_descriptors.

The old available_actions may remain temporarily for compatibility, but new process-detail UI must consume available_action_descriptors only.

```ts type ProcessFlowResponse = { process_id: string; workflow_path: string; workflow: string; // compatibility/local workflow name current_state: string | null; current_phase?: string | null; // deprecated compatibility alias entity_data: Record; prev_hash: string | null; registry_hash?: string | null; states: Array<{ name: string; label: string }>; transitions: Array<{ from: string; to: string; action: string }>; available_action_descriptors: AvailableActionDescriptor[];

// Legacy compatibility. Do not use in new UI. available_actions?: unknown[]; }; ```

AvailableActionDescriptor

```ts type AvailableActionDescriptor = { descriptor_version: 1;

// Stable action identity and dialog wiring. id: string; // stable within this process-flow response, e.g. ${workflow_path}:${process_id}:${action_name} workflow_path: string; // canonical workflow path, e.g. customer/retail workflow_name: string; // local workflow key, e.g. retail process_id: string; current_state: string | null; prev_hash: string | null; action_name: string;

// UI display only. Server owns these labels; frontend may render them but must not infer semantics from them. label: string; description?: string | null; action_type: 'transition' | 'self' | 'spawn' | 'schedule'; category?: 'primary' | 'secondary' | 'danger' | 'automation' | string;

// Availability result computed by server/Rust-WASM semantics. availability: { allowed: boolean; reason_code?: | 'allowed' | 'role_denied' | 'state_mismatch' | 'guard_failed' | 'terminal_state' | 'workflow_missing' | 'action_missing' | 'invalid_definition' | 'engine_unavailable' | string; message?: string; };

// The workflow action definition for ActionDialog input inference and audit-friendly preview context. // This is declarative metadata, not something the frontend evaluates for availability. action_definition: Record;

// Preferred input model for ActionDialog. If absent, ActionDialog may infer from action_definition. input_schema?: ActionInputSchema;

// Optional defaults only. These become initial ActionDialog form values; they must not include secrets. initial_input?: ActionInput;

// Optional render hints. UI may use these for ordering/grouping only. ui?: { order?: number; icon?: string; confirm_label?: string; disabled_hint?: string; }; }; ```

ActionInputSchema is the same shape defined in universal-action-dialog-spec.md:

```ts type ActionInputSchema = Record;

type ActionInputField = { type?: 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'textarea' | 'reference' | 'json'; label?: string; required?: boolean; section?: 'fields' | 'refs' | 'outcome' | 'note' | 'evidence' | 'amounts' | 'dates' | 'flags' | 'meta'; options?: string[]; target?: string; placeholder?: string; help?: string; }; ```

Descriptor-to-ActionDialog mapping

A process-detail component may launch ActionDialog only from a descriptor returned by the server/Rust-WASM projection.

tsx <ActionDialog open={dialogOpen} title={descriptor.label} workflowPath={descriptor.workflow_path} actionName={descriptor.action_name} processId={descriptor.process_id} currentState={descriptor.current_state ?? undefined} prevHash={descriptor.prev_hash} actionDefinition={descriptor.action_definition} inputSchema={descriptor.input_schema} initialInput={descriptor.initial_input} onCancel={closeDialog} onSubmitted={async () => { closeDialog(); await refreshProcessFlow(); }} />

Frontend rules:

  • Render only descriptors from the server response.
  • If availability.allowed === false, render the action disabled or hidden according to product UX, but do not recompute the reason.
  • Do not inspect role, from, to, guard, branches, or process state to decide availability.
  • Do not modify prev_hash; pass the descriptor value into ActionDialog.
  • Do not submit if the descriptor is stale after refresh; refresh flow after successful submission.

Server/Rust responsibilities

The descriptor producer (get_process_flow or successor) must:

  1. load the process by id,
  2. load the immutable workflow definition/version that governs the process,
  3. compute current state from process state,
  4. read actor roles from the authenticated actor/session input,
  5. evaluate action availability server-side:
  6. role match,
  7. action type/topology/current state,
  8. terminal state,
  9. guard expressions,
  10. workflow/action existence,
  11. include current prev_hash derived from the process chain_tip,
  12. include canonical workflow_path, not only local workflow name,
  13. include the action definition object exactly as deployed, and
  14. construct an ActionInputSchema from explicit action/workflow metadata where present.

process_event remains the final authority. A descriptor saying allowed: true is only a UI projection and can become stale if another event advances the process.

Input schema derivation

Preferred sources, in order:

  1. action-level input metadata:

json "actions": { "screening_decision": { "type": "transition", "role": "analyst", "from": "screening", "branches": [ { "to": "approved", "when": "input.outcome.value = 'approve'" }, { "to": "rejected", "when": "input.outcome.value = 'reject'" } ], "input": { "outcome.value": { "type": "enum", "options": ["approve", "reject"], "required": true }, "outcome.reason": { "type": "textarea", "required": false } } } }

  1. workflow-level $fields metadata for create/genesis-style entity fields:

json "$fields": { "customer_name": { "label": "Customer name", "type": "string", "required": true }, "risk_rating": { "label": "Risk rating", "type": "enum", "options": ["low", "medium", "high"] } }

For create flow, $fields.customer_name maps mechanically to fields.customer_name unless metadata explicitly supplies another canonical section.

  1. server/Rust inference from action definition expressions, matching the ActionDialog fallback:
  2. input.fields.foofields.foo
  3. input.refs.case_idrefs.case_id
  4. input.outcome.valueoutcome.value
  5. input.outcome.reasonoutcome.reason
  6. input.note.contentnote.content
  7. input.dates.foodates.foo
  8. input.flags.fooflags.foo

If neither metadata nor inference finds inputs, input_schema should be {}.

Important model note

Current ActionDef does not yet include a typed input field in Rust. Adding explicit action-level input metadata requires a model change before descriptors can emit it from deployed definitions. Until then, the descriptor producer may emit schema derived from $fields and expression inference.

Example response

json { "process_id": "cust-001", "workflow_path": "customer/retail", "workflow": "retail", "current_state": "screening", "entity_data": { "state": "screening", "customer_name": "Acme Ltd" }, "prev_hash": "0e9f...", "states": [ { "name": "draft", "label": "Draft" }, { "name": "screening", "label": "Screening" }, { "name": "approved", "label": "Approved" } ], "transitions": [ { "from": "screening", "to": "approved", "action": "screening_decision" }, { "from": "screening", "to": "rejected", "action": "screening_decision" } ], "available_action_descriptors": [ { "descriptor_version": 1, "id": "customer/retail:cust-001:screening_decision", "workflow_path": "customer/retail", "workflow_name": "retail", "process_id": "cust-001", "current_state": "screening", "prev_hash": "0e9f...", "action_name": "screening_decision", "label": "Screening decision", "action_type": "transition", "availability": { "allowed": true, "reason_code": "allowed" }, "action_definition": { "type": "transition", "role": "analyst", "from": "screening", "branches": [ { "to": "approved", "when": "input.outcome.value = 'approve'" }, { "to": "rejected", "when": "input.outcome.value = 'reject'" } ] }, "input_schema": { "outcome.value": { "section": "outcome", "type": "enum", "label": "Outcome", "required": true, "options": ["approve", "reject"] }, "outcome.reason": { "section": "outcome", "type": "textarea", "label": "Reason" } } } ] }

API shape

Preferred near-term path: extend existing RPC.

sql get_process_flow(actor_roles jsonb, process_id text) -> jsonb

The returned JSON adds:

  • workflow_path
  • prev_hash
  • registry_hash if cheaply available/needed by EventBuilder preview
  • available_action_descriptors

Compatibility:

  • Keep workflow as the local workflow name for existing UI.
  • Keep current_phase as a deprecated alias until all clients use current_state.
  • Keep available_actions until the process-detail UI migrates, then remove in a cleanup task.

Potential later path: add a narrower RPC if product/UI needs it.

sql get_available_action_descriptors(actor_roles jsonb, process_id text) -> jsonb

Do not add the narrower RPC unless it avoids real payload/performance issues; one process-flow projection is simpler and keeps graph/actions consistent.

Freshness and concurrency

Descriptors are snapshots. They can become stale after any event changes process state or previous hash.

Required behavior:

  • Descriptor includes prev_hash.
  • ActionDialog signs with that prev_hash.
  • process_event rejects stale latest-hash submissions.
  • UI refreshes process flow after successful submit or stale-hash error.

Do not hide staleness by silently swapping in a newer hash at submit time; that would sign a different event than the user previewed.

Security and audit invariants

  • Descriptors are read models only.
  • process_event must still verify actor signature, actor roles, previous hash, action existence, workflow definition, guards/topology, and chain integrity.
  • Descriptors must not include actor private keys, JWTs, signing seeds, or secrets.
  • Descriptor output may include role_required only as display/debug metadata if needed; UI must not use it to decide availability.
  • Descriptor action_definition must use canonical input, not legacy data.

Migration plan

  1. Extend FlowResponse in pg-governed-events/src/flow.rs with:
  2. workflow_path
  3. prev_hash
  4. available_action_descriptors
  5. Add Rust descriptor structs mirroring this spec.
  6. Build descriptors from the same server-side availability result currently used for available_actions.
  7. Add canonical input schema derivation:
  8. $fields for create/genesis entity actions,
  9. expression scan for input.<section>.<key>,
  10. branch outcome enum inference.
  11. Add/adjust pg-governed-events tests for:
  12. allowed action descriptor includes workflow/process/action/previous hash,
  13. denied action descriptor has allowed: false and reason code,
  14. no TypeScript/frontend availability computation required,
  15. canonical input_schema paths and no action.data.
  16. Add frontend API Zod schema and process-detail rendering only after server tests are green.
  17. Remove or deprecate legacy available_actions once no UI consumes it.

Acceptance criteria for implementation

  • get_process_flow returns available_action_descriptors with descriptor version 1.
  • Process-detail UI launches ActionDialog only from descriptors.
  • Frontend does not compute availability from role/state/guard/transition data.
  • prev_hash passed to ActionDialog is the descriptor/process previous hash.
  • Descriptor input schema uses canonical paths and never data / action.data.*.
  • Existing process_event guardrails remain intact and authoritative.
  • Tests cover allowed, denied, stale-hash/freshness expectation, and canonical input-schema output.