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:
- 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.)
- 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.)
- 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
Problem
When operators need to inject behavior into the entity lifecycle, the heaviest tool — gRPC hooks (
@hook(<event>), dispatched incrates/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: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.@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:
@webhook. No hosted service — just a URL + secret. Cannot block, abort, mutate the payload, or return data into the transaction. (Already exists.)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@webhookdelivers.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
(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.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
DynamicValuefrom 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
celcrate (supply-chain posture); vendoringcel(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'scel-cppwas never on the table — its long pole is protobuf reflection, a subsystem we don't need since our value domain isDynamicValue, 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
@requirevalidation predicates (422)@computecomputed/derived fields@default(expr: ...)expression defaultsEngine (owned CEL evaluator)
schema-forge-cel: lexer + parser → ASTType system
duration(+ timestamp arithmetic)bytes(inline binary)uint(type vs constraint)map<K,V>optionalnavigation semanticsIntegration