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
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/decision → outcome.value; reason → outcome.reason |
note, content, author |
input.note.* |
date_of_birth, date-like action payloads |
input.dates.* |
_automation |
input.meta.automation |
Renaming decision → outcome: 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_decisionis a workflow action name, not the input section; do not rename it. Onlyinput.decision.*expression/input references change. - Past events are immutable. Their bytes are never rewritten; history keeps
its original
decisionpayloads. Only new events and workflow definitions adoptoutcome. A reset/reseed deploys fresh definitions and emits fresh events withoutcome.
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
datainstead ofinput - user-supplied
input.meta.automationunless actor hasAUTOMATION - automation work row attempts to fire without
input.meta.automation auto: trueaction does not grant/includeAUTOMATIONrole- unknown top-level input section, unless future workflow version allows it
Warnings initially, then errors before completion:
- fields placed in
input.fieldsthat better fitrefs,outcome,note, ordates - 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-reactimports builder for manual actions/dialog.seed/seed.mjsimports builder or generated CJS bundle.automation-workerimports 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:
- normalize input section ordering and remove empty sections
- reject
data - canonicalize through governed-events WASM/Rust canonicalization
- sign with Ed25519
- compute hash with the current chain formula
- 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:
- Action: workflow, action, process id, current state
- Input: structured editor for
fields,refs,outcome,note,evidence,amounts,dates,flags,meta - Preview:
- canonical action JSON
- actor public key
- registry hash
- latest hash
- signature
- event hash
- 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:
- Add spec + invariant tests that can run in warning mode.
- Update Rust event model to
input. - Update expression context to expose
input. - Validator rejects
action.data/ legacydata. - Update workflow model: spawn/auto/schedule input fields.
- Add central EventBuilder package.
- Migrate seed workflows and examples.
- Migrate seed script to EventBuilder.
- Migrate automation-worker to EventBuilder and
input.meta.automation. - Migrate frontend manual action submission and dialog.
- Reset/reseed from source only.
- Remove warning-mode allowances and enforce hard errors.
Regression gates¶
Required before merge:
cargo test -p governed-events --quietcargo test -p pg-governed-events --quietcargo check --workspace --quietcd event-builder && npm testcd editor && npm test -- --runcd frontend-react && npm test -- --run && npm run buildcd 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¶
- Should
inputbe 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 {}.
- Should bare state identifiers remain in expressions?
Recommendation: yes for process state readability, but input must always be
explicitly input.*.
- Should workflow action input metadata be mandatory?
Recommendation: no for this refactor. Infer first; make explicit schemas a follow-up milestone.