Skip to content

Canonical Input and Event Builder Specification

Status

Current specification. This refactor has been implemented for active events: canonical action envelopes use input, and legacy data / action.data.* is rejected.

Goal

Make every signed action event use one canonical, structured input model and one central event builder. The same dialog and builder must serve manual UI actions, seed/reseed scripts, automation-worker fired actions, and tests.

This refactor must preserve the eFEC invariants:

  • one append-only write path: process_event
  • Ed25519 signatures over canonical JSON
  • deterministic event hashes
  • server-side workflow evaluation only
  • no TypeScript workflow semantics

Non-goals

  • Do not add per-action TypeScript workflow execution logic.
  • Do not preserve action.data.* as an active workflow namespace.
  • Do not introduce side-door writes for migration or seeding.
  • Do not store private actor keys in DB/source.

Historical problem

Before canonical input, submitted action events used a generic flat data bag:

json { "process_id": "customer-123", "prev_hash": "...", "workflow": "customer/retail/retail-customer", "name": "approve", "data": { "outcome": "approved", "reason": "risk accepted", "case_id": "case-123" } }

Workflow expressions then refer to arbitrary flat paths:

text action.data.outcome action.data.customer_name action.data.child_id

This works, but it hides intent. A reusable action dialog cannot reliably know which fields are outcomes, references, notes, evidence, or metadata.

Decision

Replace flat data with structured input everywhere.

New signed action envelope:

json { "process_id": "customer-123", "prev_hash": "...", "workflow": "customer/retail/retail-customer", "name": "approve", "input": { "outcome": { "value": "approved", "reason": "risk accepted" }, "refs": { "case_id": "case-123" }, "meta": { "idempotency_key": "approve-customer-123-001" } } }

Workflow expressions use input.*:

text input.outcome.value = 'approved' input.outcome.reason input.fields.risk_score >= 0 input.refs.child_id input.note.content input.meta.automation.kind

Active workflows must not use action.data.* after this migration.

Canonical input model

input is a structured JSON object with reserved top-level sections. Unknown custom sections are rejected unless explicitly allowed by a future workflow schema version.

```ts type ActionInput = { fields?: Record; refs?: Record; outcome?: { value?: string; // resolution token tested by guards/branches (approve/reject/clear/investigate...) reason?: string; // optional justification; never required }; note?: { content?: string; author?: string; visibility?: "internal" | "external" | string; }; evidence?: { documents?: JsonValue[]; attachments?: JsonValue[]; links?: string[]; checks?: Record; }; amounts?: Record; dates?: Record; flags?: Record; meta?: { source?: string; correlation_id?: string; idempotency_key?: string; request_id?: string; automation?: AutomationProvenance; external?: ExternalProvenance; }; };

type AutomationProvenance = { kind: "auto_action" | "scheduled_action"; work_id: string; queued_by_event_hash?: string; queued_by_action?: string; queued_by_actor?: string; schedule_id?: string; reason?: string; };

type ExternalProvenance = { provider?: string; job_id?: string; received_at?: string; }; ```

Section semantics

Section Purpose Examples
fields Business scalar/object inputs not covered by a stronger section customer_name, legal_name, risk_score, doc_count
refs References to other process/entity ids customer_id, case_id, child_id, account_id
outcome The resolution of a gate (human or system); optional, never required value, reason
note User-entered note/comment content, author, visibility
evidence Documents, attachments, links, screening check payloads sanctions hits, uploaded docs
amounts Monetary/numeric domain values exposure, limit, balance
dates Date/time values as strings date_of_birth, review due date
flags Boolean toggles attestations, manual override flags
meta Non-business event metadata/provenance idempotency, automation, external job ids

Current-field migration map

Current action.data.* New input.*
customer_name, legal_name, registration_number, entity_type, jurisdiction, risk_score, score, code, doc_count, ubo_count, screening_status, result, name_result, pep_result, kyc_result input.fields.*
customer_id, case_id, account_id, child_id, parent_id, kyc_case_id, parent_customer input.refs.*
outcome, decision (legacy routing values), reason when justifying a resolution input.outcome.*outcome/decisionoutcome.value; reasonoutcome.reason
note, content, author input.note.*
date_of_birth, date-like action payloads input.dates.*
_automation input.meta.automation

Renaming decisionoutcome: migration semantics

The previous revision used a decision section. It was conceptually too narrow: not every action resolves a gate (create, start, spawn, close, attach have none), and reason was implicitly coupled to a choice it does not always accompany. It also mis-bucketed observations (*_result, score, code) as decisions. outcome replaces it as an optional resolution section.

This is a rename plus a relocation — apply both, do not bulk-rename:

Old reference New reference Why
input.decision.outcome = 'x' input.outcome.value = 'x' routing value
input.decision.decision = 'x' input.outcome.value = 'x' unify the two inconsistent legacy names
input.decision.reason input.outcome.reason justification, optional
input.decision ? 'outcome' input.outcome ? 'value' existence/optionality guard
input.decision.name_result / pep_result / kyc_result input.fields.name_result / … observations, not resolutions
input.decision.score / code input.fields.score / code observations
  • Action names are unaffected. screening_decision is a workflow action name, not the input section; do not rename it. Only input.decision.* expression/input references change.
  • Past events are immutable. Their bytes are never rewritten; history keeps its original decision payloads. Only new events and workflow definitions adopt outcome. A reset/reseed deploys fresh definitions and emits fresh events with outcome.

Workflow DSL changes

Expression context

Workflow expression evaluation receives these roots:

Root Meaning
input Submitted event input
state Current process data/state object
actor Submitted actor/public-key context
$timestamp Captured event timestamp

State fields may remain available as bare identifiers for existing DSL readability, but new input references must use input.*.

Examples:

json { "actions": { "submit_intake": { "type": "transition", "role": "analyst", "from": "prospect", "to": "screening", "guard": "input.dates.date_of_birth IS NOT NULL AND input.fields.tax_residency IS NOT NULL", "then": "risk_score >= 0", "set": { "date_of_birth": "input.dates.date_of_birth", "tax_residency": "input.fields.tax_residency", "risk_score": "input.fields.risk_score" } }, "screening_decision": { "type": "transition", "role": "manager", "from": "screening", "branches": [ { "to": "cleared", "when": "input.outcome.value = 'clear'" }, { "to": "investigation", "when": "input.outcome.value = 'investigate'" }, { "to": "rejected", "when": "input.outcome.value = 'reject'" } ] }, "spawn_kyc": { "type": "spawn", "role": "admin,analyst", "from": "onboarding", "to": "onboarding", "spawn": [{ "workflow": "case/review/kyc-review", "process_id": "input.refs.child_id", "input": { "refs": { "parent_id": "input.refs.parent_id" } } }] } } }

Action effects

Effects may read from input.* but continue writing to process state via direct fields:

json "set": { "risk_score": "input.fields.risk_score", "decision": "input.outcome.value" }

append, merge, remove, increment, schedule, cancel, and spawn use the same expression namespace.

Spawned child input

Spawn definitions should use input, not data:

json "spawn": [{ "workflow": "screening/name/name-screening", "process_id": "input.refs.customer_id", "input": { "refs": { "parent_id": "input.refs.customer_id" }, "meta": { "source": "parent-spawn" } } }]

The engine builds child action envelopes with input.

Validation rules

Hard errors:

  • submitted action envelope contains data
  • submitted action envelope omits input
  • active workflow expression contains action.data
  • workflow spawn definition uses data instead of input
  • user-supplied input.meta.automation unless actor has AUTOMATION
  • automation work row attempts to fire without input.meta.automation
  • auto: true action does not grant/include AUTOMATION role
  • unknown top-level input section, unless future workflow version allows it

Warnings initially, then errors before completion:

  • fields placed in input.fields that better fit refs, outcome, note, or dates
  • action has no inferred/declared input schema but references input.*

EventBuilder

One central builder owns action-event construction for UI, seed, automation, and tests.

Package shape

Create a shared JavaScript package/directory:

text event-builder/ package.json src/index.ts src/input.ts src/builder.ts src/adapters.ts src/dialog-model.ts

Consumers:

  • frontend-react imports builder for manual actions/dialog.
  • seed/seed.mjs imports builder or generated CJS bundle.
  • automation-worker imports builder for scheduled/auto fired actions.
  • tests use builder fixtures.

Builder API

```ts type BuildActionEventRequest = { workflowPath: string; actionName: string; processId: string; input: ActionInput; actor: { publicKey: string; secretKey: string; }; context: { prevHash: string; registryHash: string; }; };

type BuiltActionEvent = { action: { process_id: string; prev_hash: string; workflow: string; name: string; input: ActionInput; }; canonicalActionJson: string; header: { public_key: string; signature: string; registry_hash: string; }; signature: string; eventHash: string; }; ```

The builder must:

  1. normalize input section ordering and remove empty sections
  2. reject data
  3. canonicalize through governed-events WASM/Rust canonicalization
  4. sign with Ed25519
  5. compute hash with the current chain formula
  6. produce a dialog preview object

The builder must not:

  • evaluate workflow availability
  • decide state transitions
  • mutate DB directly
  • store keys

Action dialog model

The shared action dialog consumes workflow path, action name, process id, and builder context.

Dialog sections:

  1. Action: workflow, action, process id, current state
  2. Input: structured editor for fields, refs, outcome, note, evidence, amounts, dates, flags, meta
  3. Preview:
  4. canonical action JSON
  5. actor public key
  6. registry hash
  7. latest hash
  8. signature
  9. event hash
  10. Submit: calls process_event

The dialog may infer input fields by scanning input.* references in the action definition. Later, workflows can declare explicit input schemas.

Input schema metadata

Add optional workflow action metadata for better dialogs:

json "input": { "fields.customer_name": { "type": "string", "label": "Customer name", "required": true }, "outcome.value": { "type": "enum", "options": ["approve", "reject", "investigate"], "required": true }, "refs.case_id": { "type": "reference", "target": "case/review/kyc-review" } }

This metadata is declarative UI/help only. The engine remains authoritative and must still evaluate guards/roles/server-side.

Automation migration

Current automation provenance:

json { "_automation": { "kind": "auto_action", "work_id": "..." } }

New location:

json { "meta": { "automation": { "kind": "auto_action", "work_id": "...", "queued_by_event_hash": "...", "queued_by_action": "...", "queued_by_actor": "..." } } }

Auto action config should also use input:

json "auto": { "input": { "meta": { "source": "workflow-auto" } } }

Scheduled action rows store action_input, not action_data.

Migration strategy

Hard-break migration in one branch, with ordered commits:

  1. Add spec + invariant tests that can run in warning mode.
  2. Update Rust event model to input.
  3. Update expression context to expose input.
  4. Validator rejects action.data / legacy data.
  5. Update workflow model: spawn/auto/schedule input fields.
  6. Add central EventBuilder package.
  7. Migrate seed workflows and examples.
  8. Migrate seed script to EventBuilder.
  9. Migrate automation-worker to EventBuilder and input.meta.automation.
  10. Migrate frontend manual action submission and dialog.
  11. Reset/reseed from source only.
  12. Remove warning-mode allowances and enforce hard errors.

Regression gates

Required before merge:

  • cargo test -p governed-events --quiet
  • cargo test -p pg-governed-events --quiet
  • cargo check --workspace --quiet
  • cd event-builder && npm test
  • cd editor && npm test -- --run
  • cd frontend-react && npm test -- --run && npm run build
  • cd test && npm run verify:invariants
  • source-only reset/reseed
  • live DB verification:
  • zero_sigs = 0
  • no active workflow JSON contains action.data
  • no event action contains legacy data
  • all events contain input
  • all spawn refs resolve
  • automation actor still public-key only

Open implementation questions

  1. Should input be required for every action, or default to {} if omitted by caller before signing?

Recommendation: builder normalizes missing input to {} before signing; engine rejects envelopes with neither input nor builder-normalized {}.

  1. Should bare state identifiers remain in expressions?

Recommendation: yes for process state readability, but input must always be explicitly input.*.

  1. Should workflow action input metadata be mandatory?

Recommendation: no for this refactor. Infer first; make explicit schemas a follow-up milestone.