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) andwasm32(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. Ifstatusis 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.
Step 4 — Verify Chain Link¶
- Create/branch:
prev_hashmust match the validated registry/parent branch point. - Existing process:
action.prev_hashmust matchprocess.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.namespacevia_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_flowusesload_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::canonicalize — serde_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_hash — SHA-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-bypassfeature 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 byverify_strict. - Single chokepoint (Guard #13) — database-enforced.
process_eventisSECURITY DEFINER(runs as owner).INSERT,UPDATE,DELETEare revoked on all core tables (events,processes,actors,outbox,system_log) fromPUBLICandauthenticator. The only write path is the function. - Parameterized SQL (Guard #14) — all SPI queries use
get_one_with_args/run_with_argswith$Nplaceholders. The manualq()quoting helper is deleted.is_safe_id/is_safe_hex/is_safe_namespacevalidators remain as defense-in-depth. - Genesis self-seal —
bootstrap_genesis()inserts the system registry + actors, thenRAISE EXCEPTIONifprocessesis 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_SECRETenvironment variable. - Canonicalization (Guard #3) — structurally enforced via the shared
governed-eventscrate (compiled to WASM for the browser). Proven by the parity test (governed-events/parity-test.mjs) gated in CI.