Skip to content

Epic: Built-in declarative rules engine (reduce reliance on gRPC hooks) #89

Description

@rrrodzilla

Problem

When operators need to inject behavior into the entity lifecycle, the heaviest tool — gRPC hooks (@hook(<event>), dispatched in crates/schema-forge-acton/src/hooks/mod.rs) — is currently positioned as the catch-all. But hooks require operators to write, compile, and host one or more gRPC services. Two cheaper, declarative mechanisms already exist or are within reach, and most lifecycle logic belongs in them:

  • The majority of before_* use cases are pure decisions and transforms over data already in hand — validation, conditional requiredness, cross-field invariants, computed/derived fields, expression defaults. These need no network and no hosted service. Today they still require a gRPC hook. This epic builds the rules engine that absorbs them.
  • The outbound, after-the-fact cases — notifications, event publication, calling an external system — are already served declaratively by @webhook(events, url, secret) (crates/schema-forge-acton/src/webhook.rs: fire-and-forget, never blocks the response, retry with backoff). These should not be described as hook territory.

The escalation ladder

Behavior should live on the lowest rung that can express it; climb only when the rung below genuinely cannot:

  1. CEL / rules engine — pure, in-process, synchronous. Validation, conditional requiredness, cross-field invariants, computed fields, expression defaults. Participates in the write path (can reject/transform) but only over data in hand. No service, no network. (This epic.)
  2. Webhooks — declarative, outbound, post-event, fire-and-forget. Notifications, event publication, integration with external systems, via @webhook. No hosted service — just a URL + secret. Cannot block, abort, mutate the payload, or return data into the transaction. (Already exists.)
  3. gRPC hooks — bespoke, hosted code; the genuinely-custom tail. Reserved for the two things nothing below can do: (a) synchronous participation that needs external data/side-effects mid-decision — a before_* hook that calls out and blocks/aborts/mutates on the answer (e.g. an external fraud or validation service that gates the write); and (b) post-event work too complex for a plain POST — conditional fan-out, multi-system orchestration, payload transformation that exceeds what @webhook delivers.

The thesis: rungs 1 and 2 are both declarative and together absorb the large majority of lifecycle logic, shrinking hooks to a small, well-defined bespoke tail.

Goal

Build rung 1 — a built-in declarative rules engine configured in the schema DSL (and/or companion rules files) — so the pure-logic majority is served without compiling or hosting any gRPC service.

Guiding principles

  • The dividing line is purity. Rules engine = pure functions of (new_entity, prior_entity, principal_claims[, constrained relation reads]). The instant a behavior needs a side effect or external data, it climbs the ladder (webhook for outbound notification; hook for synchronous external dependency). No I/O, no ambient authority, guaranteed-terminating.
  • Rules live in the signed schema. A declarative rule is part of the signed, version-controlled, reviewable schema artifact — an auditable control for ATO, not opaque out-of-band binary.
  • In-transaction, deterministic. Rules evaluate inline before persistence, in the same transaction, with engine-controlled ordering — sidestepping the documented hook reentrancy/ordering/consistency caveats.
  • Errors at apply, not runtime. Rule expressions are type-checked against schema field types at parse/apply, so a bad rule fails before deploy rather than on a live request.

Substrate (decided — #91)

Build our own minimal CEL evaluator over DynamicValue from scratch, verified against the cel-spec conformance oracle (#90). Engine work: #107 (lexer+parser), #108 (evaluator core + value model), #109 (standard library).

Rejected and recorded: a live dependency on the unmaintained cel crate (supply-chain posture); vendoring cel (no desire to inherit its design/gaps); and Cedar for the rules engine (hard no — Cedar stays scoped to authorization, produces no values). Rationale captured in the #91 ADR. Porting Google's cel-cpp was never on the table — its long pole is protobuf reflection, a subsystem we don't need since our value domain is DynamicValue, not protobuf messages. We build only the subset the rules engine requires, fully owned, FIPS/airgap-clean.

Non-goals (handled by rungs 2 and 3, not this epic)

Outbound notifications / event publication / external integration → webhooks (@webhook). Synchronous external dependencies in the write path, and post-event orchestration beyond a simple POST → gRPC hooks. Anything non-deterministic or side-effecting is out of scope for the rules engine by definition.

Workstreams

Sub-issues are tracked by GitHub below. Grouped: Foundation (substrate de-risking), Capabilities (the declarative behaviors), Type system (CEL value types our DSL lacks), Integration (DSL/parser/type-check/ordering/signing).

Foundation

Capabilities

Engine (owned CEL evaluator)

Type system

Integration

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepicTracking epic / parent issue

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions