Skip to content

Engine Specification

The engine is two Rust crates:

  • governed-events — pure Rust + WASM. Crypto, hash chain, canonical JSON, workflow model, expression evaluator, mutation operators. ~3,500 lines. Compiles to both native (used by the PG extension) and wasm32 (used by the browser).
  • pg-governed-events — the PostgreSQL extension (pgrx). Thin DB-binding layer: process_event(), get_process_flow(), SPI queries, privilege lockdown. ~2,450 lines.

Source: governed-events/src/ + pg-governed-events/src/.


Architecture

Three Tables

```sql actors ( id TEXT PRIMARY KEY, public_key TEXT NOT NULL DEFAULT '', data JSONB NOT NULL DEFAULT '{}', roles TEXT[] NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'ACTIVE' )

processes ( id UUID PRIMARY KEY, namespace TEXT NOT NULL, workflow TEXT, is_registry BOOLEAN NOT NULL DEFAULT false, data JSONB NOT NULL DEFAULT '{}', chain_tip TEXT, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now() )

events ( event_hash TEXT PRIMARY KEY, process_id UUID NOT NULL, prev_hash TEXT NOT NULL, action JSONB NOT NULL, header JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) ```

Actors: id is a stable identifier (e.g., sarah.chen). public_key is the Ed25519 hex key. data holds display information (name). roles controls authorization (case-insensitive matching).

Processes:

Column Purpose
namespace Isolated domain path such as customer/corporate or case/review. Resolved to a registry for workflow lookup.
workflow Workflow basename governing the process, e.g. corporate-customer or kyc-review. NULL for non-governed/system rows.
is_registry true if this process holds data.workflows for its namespace.
data Process state JSONB. Registries store data.workflows here.
chain_tip SHA-256 hash of the most recent event — the tip of this process's hash chain.
status active, terminal, or other computed states.

Events: content-addressed by event_hash with process-local chain columns plus JSONB envelope fields: - process_id — process whose chain this event extends. - prev_hash — witnessed previous tip for that process. - header{ "public_key", "signature", "registry_hash" }. - action{ "process_id", "prev_hash", "name", "workflow", "input" }.


The Header

json { "public_key": "df7238e2...", "signature": "00ff11ee...", "registry_hash": "0000000000000000000000000000000000000000000000000000000000000000" }

Field Meaning
public_key Ed25519 hex key of the signer. Resolved to an actor at verification time.
signature Ed25519 detached signature (64-byte hex) over canonical action bytes. There is no bypass — every event, including genesis, is real-signed and verified via verify_strict.
registry_hash Informational — records which registry state the client witnessed. Not used by the engine for verification (registry is resolved from the process's namespace at event time).

The Action Object

json { "process_id": "32f80f1c-cd20-410a-975a-007fa17078c2", "prev_hash": "9ded3574...", "name": "approve", "workflow": "case/review/kyc-review", "input": { "fields": { "score": 85 } } }

Field Meaning
process_id UUID of the process whose chain is extended.
prev_hash Witnessed from processes.chain_tip. Must be 0000... (64 zeros) only for root genesis/bootstrap cases; normal child/process creation uses an explicit prior registry/parent event.
name Workflow action name to execute (e.g., "create", "approve", "add_note").
workflow Canonical workflow path or resolved workflow identity for this action.
input Canonical business input envelope. Legacy data / action.data.* is rejected.

What Gets Signed

The actor cryptographically attests to the complete canonical action object: process_id, prev_hash, workflow, action name, and canonical input. The canonical action JSON is signed with Ed25519. The registry_hash is bound into the event hash but NOT signed by the actor — the engine reads the current registry state, not the client's claim.


Event Hash

event_hash = SHA-256( canonical_action_json_utf8 || public_key_hex_utf8 || signature_raw_bytes ← NOT the hex string || registry_hash_hex_utf8 ) → hex (64 chars)

The signature bytes pass through raw (not hex-reencoded) into the hash. This means two different hex-encodings of the same signature produce different event hashes — only the raw 64-byte Ed25519 signature is canonical.

The engine computes this hash in governed_events::crypto::compute_event_hash() and stores it as the process's chain_tip. The browser computes the same result via the WASM build of the same function — drift is structurally impossible (Guard #3).


Namespaces & Registries

Namespaces are flat, isolated domains. Each namespace has exactly one registry process (is_registry = true) that holds data.workflows.

system registry namespace: system is_registry: true data.workflows: { bootstrap } customer registry namespace: customer/corporate is_registry: true data.workflows: { corporate-customer } case registry namespace: case/review is_registry: true data.workflows: { kyc-review } baram2 namespace: customer/corporate workflow: corporate-customer baram2-kyc namespace: case/review workflow: kyc-review

Live Steering

The registry is a live steering mechanism. When a workflow is updated in the registry, it applies to all future events on all processes in that namespace. The engine resolves the registry at event time from the process's current namespace — there is no per-process freezing or hash walkback.


Trigger Flow (process_event)

The 13-step pipeline in trigger.rs:

Step 1 — Validate Fields

action must contain process_id, prev_hash, name, workflow, and canonical input. header must contain non-empty public_key, signature, registry_hash.

Step 2 — Capture Timestamp

chrono::Utc::now().to_rfc3339() — used by $timestamp in mutate expressions.

Step 3 — Resolve Process

  • Found by process_id: return existing process. If status is terminal, reject.
  • Create/branch actions: explicit create events establish the new process branch from a registry/parent tip; child processes are normal processes, not hidden side effects.
  • Not found without a valid create/branch context: reject.
  • Create/branch: prev_hash must match the validated registry/parent branch point.
  • Existing process: action.prev_hash must match process.chain_tip.

Step 5 — Verify Event Hash

Compute SHA-256 from action, header.public_key, header.signature (raw bytes), header.registry_hash and compare to the passed event_hash.

Step 6 — Verify Signature

Ed25519 detached verification via ed25519_dalek::VerifyingKey::verify_strict(). Signed bytes are the canonical action JSON (via serde_json with BTreeMap key sorting). No bypass — every signature is real.

Step 7 — Resolve Actor

Look up actors WHERE public_key = header.public_key. Must exist and be ACTIVE. Roles are case-insensitive comma-separated strings.

Step 8 — Load Workflow

  • Resolve registry from process.namespace via _fec_find_registry() SQL.
  • Load workflow definition from registry.data.workflows (deserialize JSON → WorkflowDef).
  • Fall back to hardcoded bootstrap_workflow() for system registry bootstrap.
  • get_process_flow uses load_workflow_from_registries() which scans ALL registries.

Step 9 — Evaluate Workflow

9a. Role check: Actor::check_role(&action_def.role) — comma-split, case-insensitive.

9b. When (pre-condition): Expression evaluated against process.data plus canonical input. Uses the Rust expression parser in workflow/expr.rs (NOT SQL EXECUTE). Example: phase = 'draft' AND input.fields.score > 0.

9c. Mutate: Apply $set, $append, $merge, $remove, $increment operators. input.fields.x references are resolved recursively. $timestamp resolves to the timestamp captured in step 2.

9d. Then (post-condition): Expression evaluated against new_data after mutation.

Step 10 — Auto-Dispatch

If workflow_def.import contains the action name → write to outbox table.

Step 11 — Record Event

INSERT INTO events (event_hash, action, header, created_at).

Step 12 — Upsert Process

INSERT ... ON CONFLICT UPDATE into processes. New chain_tip = event hash. status computed by ProcessState::compute_status().

Step 13 — System Log

Insert into system_log for audit trail.


Expression Evaluation

The Rust expression parser (workflow/expr.rs, 709 lines) evaluates when and then expressions. It tokenizes SQL-like syntax into an AST and evaluates against JSONB values.

when: "phase = 'draft'" → process.data.phase == "draft" when: "score > 0" → process.data.score > 0 when: "phase IN ('draft', 'running')" → process.data.phase in ["draft", "running"] then: "phase = 'approved'" → new_data.phase == "approved"

Operators supported: =, !=, >=, <=, >, <, IN, IS NULL, IS NOT NULL, AND, OR, NOT, ? (JSONB key-exists), unary -.

Not supported: binary - (subtraction), +, *, /, complex JSON path expressions. Expressions like "score > 5 - 3" will cause a parse error.


Mutation Operators

Defined in workflow/mod.rs:

Operator Arguments Effect
$set { "key": value } Set top-level keys. String/input references such as input.fields.score are resolved from the canonical action input.
$append { "key": value } Append to an array at key. Also used for $timestamp resolution.
$merge { "key": value } Shallow-merge an object at key. Supports $fec references resolved recursively.
$remove { "key": null } Remove a top-level key.
$increment { "key": "1" } Increment a numeric field by the given amount.

$timestamp special keyword: resolves to the ISO 8601 timestamp captured when the event began processing. Available in any mutate value, e.g.: json "mutate": { "$append": { "notes": { "content": "input.fields.content", "time": "$timestamp" } } }


Terminal States

ProcessState::compute_status() checks new_data.phase against a set of terminal values: closed, cancelled, rejected, done, completed, expired, approved, deployed, suspended. Once terminal, no further events accepted on the process.


Outbox

sql outbox ( id TEXT PRIMARY KEY, process_id TEXT NOT NULL, target TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now() )


Consistency Requirements

The engine (Rust) and browser (WASM) compile the same crate (governed-events) for canonicalization, signing, and hashing — not parallel implementations tested to agree. This makes Guard #3 (canonicalization sacred) a structural invariant, proven by the parity test in CI.

Concern Implementation Notes
Canonical JSON governed_events::canonicalizeserde_json with BTreeMap, space after : and , Same code, native + WASM
Signature governed_events::sign — Ed25519 (ed25519_dalek), verify_strict 64-byte detached signature
Event hash governed_events::compute_event_hashSHA-256(action_json ‖ pk_hex ‖ sig_raw_bytes ‖ prev_hash_hex) Raw sig bytes in the hash
Role matching case-insensitive, comma-split (eq_ignore_ascii_case)

Actor Key Rotation

sql UPDATE actors SET public_key = '<new_key>' WHERE id = actor_id;

Past events self-verify because header.public_key records the key that was valid at event time.


Security Model

  • No bypass (Guard #5, #13) — the dev-bypass feature and the "0000" signature short-circuit are gone entirely. No feature flag, no debug mode, no crypto escape hatch. Every event — including genesis — carries a real Ed25519 signature verified by verify_strict.
  • Single chokepoint (Guard #13) — database-enforced. process_event is SECURITY DEFINER (runs as owner). INSERT, UPDATE, DELETE are revoked on all core tables (events, processes, actors, outbox, system_log) from PUBLIC and authenticator. The only write path is the function.
  • Parameterized SQL (Guard #14) — all SPI queries use get_one_with_args / run_with_args with $N placeholders. The manual q() quoting helper is deleted. is_safe_id / is_safe_hex / is_safe_namespace validators remain as defense-in-depth.
  • Genesis self-sealbootstrap_genesis() inserts the system registry + actors, then RAISE EXCEPTION if processes is already non-empty.
  • All user input flows through JSONB — no raw SQL injection surface for action input.
  • JWT secret for PostgREST is read from PGRST_JWT_SECRET environment variable.
  • Canonicalization (Guard #3) — structurally enforced via the shared governed-events crate (compiled to WASM for the browser). Proven by the parity test (governed-events/parity-test.mjs) gated in CI.