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:
- fetch a process flow/projection from the server,
- render available action descriptors as buttons/menu items,
- pass one descriptor directly into
ActionDialog, and - 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
dataoraction.data.*. - Do not move signing/canonicalization/hash construction out of the shared EventBuilder path.
- Do not make descriptors a security boundary.
process_eventremains authoritative and must re-check the workflow at mutation time.
Current state¶
Current server/Rust surface:
pg-governed-events/src/flow.rsimplementsget_process_flow(actor_roles, process_id).- The response includes
available_actions, but its current shape is legacy UI-oriented: namerole_requiredwhen_conditionthen_conditionaction_allowedmutaterequired_inputs- Current
required_inputsare inferred from effect expressions and are not the canonicalActionInputSchemaused byActionDialog. - Current
get_process_flowloads process data and workflow definitions server-side and already owns role/topology/guard projection.
Current frontend surface:
ActionDialogaccepts:workflowPathactionNameprocessIdcurrentState?prevHash?actionDefinition?inputSchema?ActionDialogrenders input metadata and callsprepareAction(...)+submitEvent(...).ActionDialogmust 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
// 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 intoActionDialog. - 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:
- load the process by id,
- load the immutable workflow definition/version that governs the process,
- compute current state from process state,
- read actor roles from the authenticated actor/session input,
- evaluate action availability server-side:
- role match,
- action type/topology/current state,
- terminal state,
- guard expressions,
- workflow/action existence,
- include current
prev_hashderived from the processchain_tip, - include canonical
workflow_path, not only local workflow name, - include the action definition object exactly as deployed, and
- construct an
ActionInputSchemafrom 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:
- action-level
inputmetadata:
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 }
}
}
}
- workflow-level
$fieldsmetadata 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.
- server/Rust inference from action definition expressions, matching the ActionDialog fallback:
input.fields.foo→fields.fooinput.refs.case_id→refs.case_idinput.outcome.value→outcome.valueinput.outcome.reason→outcome.reasoninput.note.content→note.contentinput.dates.foo→dates.fooinput.flags.foo→flags.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_pathprev_hashregistry_hashif cheaply available/needed by EventBuilder previewavailable_action_descriptors
Compatibility:
- Keep
workflowas the local workflow name for existing UI. - Keep
current_phaseas a deprecated alias until all clients usecurrent_state. - Keep
available_actionsuntil 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. ActionDialogsigns with thatprev_hash.process_eventrejects 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_eventmust 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_requiredonly as display/debug metadata if needed; UI must not use it to decide availability. - Descriptor
action_definitionmust use canonicalinput, not legacydata.
Migration plan¶
- Extend
FlowResponseinpg-governed-events/src/flow.rswith: workflow_pathprev_hashavailable_action_descriptors- Add Rust descriptor structs mirroring this spec.
- Build descriptors from the same server-side availability result currently used for
available_actions. - Add canonical input schema derivation:
$fieldsfor create/genesis entity actions,- expression scan for
input.<section>.<key>, - branch outcome enum inference.
- Add/adjust pg-governed-events tests for:
- allowed action descriptor includes workflow/process/action/previous hash,
- denied action descriptor has
allowed: falseand reason code, - no TypeScript/frontend availability computation required,
- canonical
input_schemapaths and noaction.data. - Add frontend API Zod schema and process-detail rendering only after server tests are green.
- Remove or deprecate legacy
available_actionsonce no UI consumes it.
Acceptance criteria for implementation¶
get_process_flowreturnsavailable_action_descriptorswith descriptor version 1.- Process-detail UI launches
ActionDialogonly from descriptors. - Frontend does not compute availability from role/state/guard/transition data.
prev_hashpassed toActionDialogis the descriptor/process previous hash.- Descriptor input schema uses canonical paths and never
data/action.data.*. - Existing
process_eventguardrails remain intact and authoritative. - Tests cover allowed, denied, stale-hash/freshness expectation, and canonical input-schema output.