Skip to content

Epic #89: built-in declarative CEL rules engine + skill-reference sweep#116

Merged
rrrodzilla merged 26 commits into
mainfrom
dev
May 29, 2026
Merged

Epic #89: built-in declarative CEL rules engine + skill-reference sweep#116
rrrodzilla merged 26 commits into
mainfrom
dev

Conversation

@rrrodzilla
Copy link
Copy Markdown
Contributor

Merges the completed epic #89 (built-in declarative rules engine) and the skill-reference documentation sweep from dev into main.

What's included (25 commits)

CEL rules engine (epic #89 — now closed):

Skill references (skills/schemaforge/):

  • Brought all references current with the rules engine, new types, and before_validate
  • Imported 5 previously-untracked reference files (cli/config/dsl-quickref/rest-api/signing)

Notes

  • dev and main diverged (main carries the demo-console PRs feat(serve): embed and serve the ops console at /console #110–112), so this is a 3-way merge — expect to resolve conflicts in Cargo.lock, crate versions, and any shared demo schema/template files.
  • ~2154 workspace tests pass under --features surrealdb on dev.

Follow-on (not in this PR): #115 Kani verification of the CEL scalar core.

rrrodzilla added 26 commits May 28, 2026 22:25
Stand up the schema-forge-cel crate and the acceptance oracle the
from-scratch CEL evaluator will be built against (epic #89, decision #91:
own minimal CEL over DynamicValue — no cel crate, no Cedar).

The harness vendors the google/cel-spec `simple` corpus filtered to the
SchemaForge-relevant subset (proto-message sections excluded by design,
since our value domain is DynamicValue), plus the protos needed to decode
it. build.rs pre-encodes each textproto to binary via protoc so the test
binary is protoc-free; generated proto types stay confined to the test and
never touch the engine's public API.

It decodes and runs 1,665 tests across 18 features, compares each against
its expected value/error, fails on any unclassified corpus file (no silent
coverage gaps), logs excluded features, and enforces a ratcheting pass
baseline that #107/#108/#109 will raise as they turn sections green.

The engine itself is a stub: evaluate() returns EvalError::unimplemented,
so the oracle currently reports an honest red baseline (0 passing).
Hand-written byte-level lexer and precedence-climbing recursive-descent
parser for the full CEL expression grammar, producing a typed AST. No
parser-generator dependency (FIPS/airgap-clean supply chain); pure std.

- lexer: positions (offset/line/col), hex/uint/double numbers, all string
  quote forms (single/double/triple), raw and bytes prefixes, every escape
  class, comments.
- ast: typed Expr/Literal with dedicated Unary/Binary/Ternary nodes,
  Comprehension model, and an invertible unparse() for debugging.
- parser: iteration macros (all/exists/exists_one/map/filter) and has()
  lower to comprehension / test-only Select nodes at parse time, using the
  canonical @Result accumulator name so #108 and the conformance corpus
  align.
- error: ParseError gains optional Position (offset + line/col).

Acceptance: tests/parse_smoke.rs parses every expr in the parse + plumbing
corpus (224/224, 0 skipped); 43 unit/integration tests green; clippy clean
with no lint suppression. evaluate() stays stubbed and the #90 conformance
baseline stays 0 — the parse section is end-to-end and flips green in #108.

Bumps schema-forge-cel 0.1.0 → 0.2.0 (additive public API).
Evaluate the #107 AST to CelValue. Pure, no I/O, guaranteed-terminating,
with a recursion depth guard (DEFAULT_MAX_DEPTH=250) returning an error
instead of overflowing the stack (government-production DoS hardening).

- eval/mod.rs: scoped recursive evaluator; logical &&/|| with commutative
  error/short-circuit absorption; ternary (only taken branch); comprehension
  evaluation with @not_strictly_false-style error deferral for all/exists,
  verified against the corpus cases.
- eval/ops.rs: pure arithmetic (checked int/uint overflow → "return error
  for overflow"; divide/modulus by zero), cross-type numeric comparison with
  exact i64/u64/f64 boundary handling, cel_equals (the CEL == operator,
  distinct from CelValue's type-exact PartialEq used for result matching;
  NaN != NaN; recursive list/map; cross-type numeric map keys), indexing,
  membership, size_of.
- eval/funcs.rs: function-dispatch seam + the core fns the acceptance
  sections need: size, dyn, type. Broad stdlib deferred to #109.
- value/mod.rs: corrected the PartialEq-vs-operator-equality comment.

Conformance: 815 passing (was 0). lists fully green; macros 42/44; basic,
fp_math, integer_math, parse light up via evaluation. MIN_PASS_BASELINE
ratcheted 0 → 815. 78 tests green, clippy clean, no lint suppression.

Bumps schema-forge-cel 0.2.0 → 0.3.0.
Fill the #108 funcs::dispatch seam with the CEL standard library over
DynamicValue, raising conformance 815 → 1018.

- funcs/convert.rs: int/uint/double/string/bytes/bool/timestamp/duration
  conversions with CEL range/format error semantics; Go-style duration parser.
- funcs/strings.rs: contains/startsWith/endsWith and matches (RE2-style
  unanchored regex search; linear-time, no ReDoS — see comment).
- funcs/time.rs: timestamp/duration accessors (getFullYear..getMilliseconds)
  honoring CEL's 0-/1-based conventions, with named-IANA and fixed-offset
  timezone args.
- ops.rs: timestamp/duration arithmetic (ts±dur, ts−ts, dur±dur) with
  cel-go's int64-nanosecond range checks; specific (double,double) `%`
  overload message.
- eval/mod.rs: bare type identifiers (int, string, …) denote `type` values
  per the CEL standard environment, as a fallback after binding lookup.
- conformance.rs: separator-insensitive eval-error matching (reclaims
  no_such_overload/divide-by-zero spelling variants; never affects value
  matching); MIN_PASS_BASELINE 815 → 1018.

Sections now fully green: string (51/51), fp_math (30/30), macros (44/44),
lists. conversions 108/109, timestamps 73/76, integer_math 59/64 — remaining
reds are #114 (i64::MIN literal) and namespace-qualified type names, not
stdlib bugs. Deferred *_ext functions are listed in funcs/mod.rs (no silent
gaps).

Adds deps: regex (RE2 matcher), chrono-tz (IANA timezones) — both pure-Rust,
no native deps, consistent with the #91 from-scratch decision.

Bumps schema-forge-cel 0.3.0 → 0.4.0.
CEL has no negative integer literal token, so i64::MIN is unary minus
applied to the magnitude 2^63 — which does not fit i64. The lexer now emits
a dedicated IntMinMagnitude token for exactly 2^63 (decimal or hex); the
parser folds a unary `-` immediately applied to it into Int(i64::MIN)
(rather than negating, which would overflow). A bare 2^63 with no minus
stays an out-of-range error.

Conformance 1018 → 1037: integer_math now fully green (64/64); the fix also
lifts basic (42/43) and comparisons (+13). MIN_PASS_BASELINE ratcheted to
1037. 98 tests green, clippy clean.
Lower and evaluate the cel-spec two-variable iteration macros, turning the
macros2 conformance section fully green (0 → 46/46).

- ast: Comprehension gains iter_var2: Option<String>. None = single-var
  (unchanged); Some = two-var. unparse inverts the two-var and
  transformList/transformMap shapes (camelCase names).
- eval: range_elements yields (var1, Option<var2>) pairs — list →
  (int index, element), map → (key, value) for two-var; single-var path
  preserved. New Scope::with3 + bind_iteration; the existing
  @not_strictly_false error-deferral loop is reused unchanged.
- funcs: internal @mapInsert(map, key, value) backs transformMap's
  accumulator step (@-prefix is unspellable in surface CEL).
- parser: lower all/exists/existsOne (3-arg), transformList (3/4-arg),
  transformMap (3/4-arg) to two-var Comprehension nodes; iteration vars
  must be bare idents; wrong arity falls back to a method Call.

Conformance 1037 → 1083; macros (single-var) stays 44/44, no regressions.
116 tests green, clippy clean, no lint suppression.

Bumps schema-forge-cel 0.4.0 → 0.5.0.
Wire the owned CEL engine into the SchemaForge DSL as first-class field
annotations, with file:line:col diagnostics on malformed expressions and
full schema round-trip. Grammar + parsing only — runtime enforcement
(#92/#93/#94) and apply-time type-checking (#104) are separate.

Syntax (positional, CEL as a double-quoted string):
  age: Integer  @require("age >= 18", "must be 18 or older")
  name: Text    @compute("first + ' ' + last")
  ts: DateTime  @default("now()")

- core: FieldAnnotation gains Require { expr, message }, Compute { expr },
  Default { expr } (the expression default, distinct from the literal
  `default(...)` FieldModifier). serde + Display round-trip with DSL string
  escaping; core stays cel-free (stores raw expr source only).
- dsl: parser arms validate the CEL source syntactically via
  schema_forge_cel::parse; on error the intra-expression position is mapped
  through DSL string escapes to an absolute line:column so the diagnostic
  points into the expression. New DslError::InvalidCelExpression. printer +
  round-trip restored. Adds schema-forge-cel as a path dep (no cycle).

628 tests pass, clippy clean, no lint suppression.
Bumps schema-forge-core 0.15.0 → 0.16.0, schema-forge-dsl 0.9.0 → 0.10.0.
Add write-time evaluation of `@require("<expr>", "<message>")` CEL
validation rules, rejecting entity writes with HTTP 422 when a predicate
is not `true` — in-transaction, before persistence, with no gRPC hook
service deployed.

- cel: new `value::bridge` with pure `dynamic_to_cel` / `cel_to_dynamic`
  conversions across the `DynamicValue` / `CelValue` boundary (the inverse
  direction is used by #93/#94). Refs surface as id strings; CEL-internal
  types without a storage representation (bytes/duration/type) and uint
  overflow fail with `ConversionError`. Bumps cel 0.5.0 → 0.6.0.
- acton: new HTTP-free `rules` module — `build_bindings` (entity fields
  plus a `principal` map from claims) and `check_requires`. Fail-closed
  for a government audit target: a predicate passes only on `Bool(true)`;
  a definite `false` is collected as a 422 rejection (all messages, in
  schema-declaration order); a non-bool result or an evaluation error
  blocks the write as a 500, never letting it through.
- Wire `check_requires` into create/update/patch handlers after the
  before_change hook and before persistence (PATCH evaluates the full
  post-patch view). `RuleError → ForgeError` mapping kept at the handler
  boundary so `rules` stays HTTP-free.

Tests: 17 bridge unit tests, 9 rules unit tests (incl. cross-field
invariant, principal predicate, and every fail-closed branch), and an
end-to-end integration test (201 valid / 422 invalid with message).
Add write-time evaluation of `@compute("<expr>")` CEL expressions,
storing the derived value into the entity before persistence — no gRPC
hook service deployed.

- acton: new `rules::apply_computed` plus a pure `coerce_to_field_type`
  helper. Computed values are STORED (not virtual) and OVERWRITE any
  client-supplied value for the field, so a client cannot smuggle a value
  into a server-derived field. Fields evaluate in schema-declaration
  order with bindings rebuilt from the current field set before each
  compute, so a later computed field can read an earlier one (chainable).
  Fail-closed: an eval/conversion/coercion failure returns a 500 and
  stores nothing for that field — never a half-evaluated value.
- `coerce_to_field_type` handles the safe, lossless cases the natural
  CEL→DynamicValue mapping leaves open: Float field + Integer → Float;
  Enum field + Text → validated Enum variant (else error); DateTime field
  + Text → parsed RFC 3339 (else error). Every other pair passes through.
  Strict apply-time type-checking against the field type is #104.
- Wire `apply_computed` before `check_requires` in create/update/patch
  (PATCH on the merged post-patch view), so `@require` validates the
  computed values and the PATCH delta picks up computed changes.

Tests: 9 rules unit tests (numeric/string compute, client-value
overwrite, chained compute, principal reference, eval error, enum
coercion success/failure, datetime parse) and an end-to-end integration
test asserting derivation and overwrite.
Add write-time evaluation of `@default("<expr>")` CEL expression
defaults, seeding absent fields on entity creation — no gRPC hook
service deployed.

- acton: new `rules::apply_defaults`, insert-only (wired into
  create_entity only, never PUT/PATCH). Fills a field only when it is
  absent or explicitly null; a non-null value already present (client,
  @owner/tenant/audit stamp, or before-hook) is left untouched. Runs
  first in the rule order (default → compute → require) so a computed
  field can read a defaulted one and @require validates the finalized
  entity. Fail-closed: eval/conversion/coercion failure → 500, nothing
  stored. Reuses the pure `coerce_to_field_type` helper.
- Distinct from the literal `FieldModifier::Default` (storage-layer SQL
  DEFAULT), whose behavior is unchanged. Precedence with @owner is
  documented and tested: @owner stamps before hooks, defaults fill only
  absent/null, so @owner always wins.
- The CEL engine is intentionally pure (no I/O, no ambient authority) and
  exposes no `now()` function. Instead each handler captures one
  request-time instant (`rules_now`, reused for audit columns) and the
  rule API binds it as a `now` timestamp variable, available to all three
  rule families. So the issue's `now()` example is spelled `@default("now")`
  — deterministic and auditable, one instant per write. `now: DateTime<Utc>`
  is threaded through build_bindings/check_requires/apply_computed/apply_defaults.

Tests: 10 new rules unit tests (now/principal defaults, absent-vs-null,
no-override, chained default, @owner precedence, eval error, and a
@require referencing `now`) and an end-to-end integration test.
…104)

Add apply-time static type-checking of CEL rule expressions
(@require/@compute/@default) against schema field types, wired into the
DSL parse pipeline so a type-incorrect rule fails before deploy with a
file:line diagnostic instead of on a live request.

New pure `check` module in schema-forge-cel:
- `infer` is a TOTAL function over the typed AST returning `InferredType`
  (`Dyn` | `Known(CelType)`); it yields `Dyn` for anything it cannot pin
  down (unknown idents, selects, indexes, unmodeled calls), so the checker
  is conservative and never rejects a valid rule.
- `field_accepts` mirrors the runtime coercions in rules.rs/bridge.rs
  (Float accepts Int; DateTime accepts String; etc.) so a rule that would
  succeed at write time is never flagged.
- `check_rule` errors ONLY when the top-level result type is definitely
  known and definitely incompatible: @require must be bool; @compute and
  @default must be assignable to the field type. `#[non_exhaustive]`
  FieldType/Cardinality variants fail open.
- `rule_type_env` binds every sibling field plus `principal` (Map) and
  `now` (Timestamp), matching rules.rs::build_bindings.

DSL: capture each rule expression's source span, then run a per-schema
type-check pass in parse_schema, surfacing failures as a new
`DslError::RuleTypeError { message, line, column, span }`. The CLI renders
it with a span underline and fix suggestion.

cel 0.6.0 -> 0.7.0; dsl 0.10.0 -> 0.11.0; cel pin bumped in dsl + acton.
…105)

Establish the canonical in-transaction write-path ordering for
create/update/patch:

  @default -> @compute -> @require -> before_* hooks -> PERSIST
  -> { after_* hooks, webhook dispatch }

Previously the before_validate/before_change gRPC hooks ran *before*
the three rule phases. Since rules are pure and cheap, they now run
first so a @require rejection (422) short-circuits the whole write
before any hook network round-trip and before persistence. Post-
persistence fan-out (after_* hooks via HookDispatchActor, webhook
delivery) stays detached and is suppressed entirely on rejection.

Reorder is surgical (move the rule block ahead of the hook block in
all three handlers); no API, type, or actor-message changes. Existing
hook integration tests carry no rule annotations, so behavior for them
is unchanged.

Tests:
- rule_phase_order_default_then_compute_then_require_is_observable
  (integration.rs): a compute reads a defaulted sibling and a require
  validates the computed value, observable end-to-end.
- require_rejection_fires_no_before_or_after_hook_and_persists_nothing
  and passing_require_reaches_before_and_after_hooks
  (hooks_integration.rs): a @require 422 fires no before_*/after_* hook
  and persists nothing; a valid write reaches both hooks.

Docs: authoritative ordering stated on the rules.rs and hooks module
docs, plus docs/rule-ordering-reference.md.
…106)

Declarative rules (@require/@compute/@default) live as annotation text
inside the .schema file, so the per-file signature and the manifest's
pinned sha256 already cover them like any other byte — the audit win
over an opaque out-of-band hook binary.

Add enforce_mode_rejects_tampered_rule_annotation: sign a schema whose
gating logic is entirely declarative, then weaken only the @require
threshold (>= 0 -> >= -999999) in the .schema bytes and confirm verify
under enforce rejects it with VerifyError::HashMismatch. No production
code change — this formalizes and pins existing signing behavior.

The reviewer-facing audit story (how to enumerate every rule gating an
entity: read the signed .schema / sf parse, then sf verify for
provenance) is documented in docs/rule-ordering-reference.md.
Add a first-class `duration` scalar end-to-end so a duration field
round-trips DSL -> core -> cel -> storage -> API, and rules can do
timestamp/duration arithmetic (e.g. now - created_at < duration('220752000s')).

- core: FieldType::Duration + DynamicValue::Duration(chrono::TimeDelta);
  new types::duration module with Go-style format/parse helpers and a
  custom DurationParseError; query.rs type-name/compat arms.
- dsl: 'duration' keyword (lexer/token), parse_type, printer round-trip.
- cel: bridge both directions (DynamicValue::Duration <-> CelValue::Duration);
  check.rs infers/accepts CelType::Duration.
- postgres: BIGINT column storing signed nanoseconds (num_nanoseconds),
  bind/read/array round-trip, fail-closed on i64-nanosecond overflow.
- surrealdb: native duration column + Value::Duration round-trip. SurrealDB's
  native duration is unsigned, so a negative duration is REJECTED fail-closed
  on write (BackendError::ValidationFailed -> HTTP 422) rather than silently
  stored as NULL; the check recurses through arrays and composites.
- acton: REST request->persist->response round-trip; canonical JSON wire
  form is the Go-style seconds string (e.g. '220752000s').
Add a first-class `bytes` scalar (inline binary) end-to-end so a bytes
field round-trips DSL -> core -> cel -> storage -> API, and rules can
size/compare bytes and use base64.encode/base64.decode. Models the #96
`duration` precedent; `bytes` additionally carries an optional max_size.

- core: FieldType::Bytes(BytesConstraints { max_size: Option<usize> }) +
  DynamicValue::Bytes(Vec<u8>); new types::bytes_constraints and a shared
  types::base64 codec (standard padded encode/decode + a padding-indifferent
  decode for CEL); query.rs type-name/compat arms; Display = standard base64.
- dsl: `bytes` / `bytes(max: N)` keyword (lexer/token), parse_bytes_params,
  printer round-trip.
- cel: bridge both directions (DynamicValue::Bytes <-> CelValue::Bytes,
  replacing the Unsupported("bytes") stub); check.rs infers/accepts
  CelType::Bytes and models base64.encode->String / base64.decode->Bytes.
- cel stdlib: encoders extension base64.encode/base64.decode (cel-spec),
  shared codec, parser lowers `base64.encode(x)`/`decode(x)` to namespaced
  global calls; encoders_ext conformance section now fully green (4/4),
  baseline 1083 -> 1146. Adds the `base64` crate to schema-forge-cel.
- postgres: BYTEA column; bind/read/array Vec<u8> round-trip; emits
  `CHECK (octet_length(col) <= N)` when max_size is set; JSON = base64.
- surrealdb: native `bytes` column + Value::Bytes round-trip; max_size
  enforced fail-closed on write (BackendError::ValidationFailed -> 422),
  recursing through arrays and composites.
- acton: JSON wire form is standard base64 with padding on both request
  parse (base64 -> Vec<u8>, invalid/oversized -> 422) and response
  serialize; schema POST accepts "Bytes" and {"type":"Bytes","data":
  {"max_size":N}}.
Add a typed, open-keyed `map<string, V>` field type end-to-end, distinct
from `Composite` (fixed declared field set) and `Json` (untyped). A map
field round-trips DSL parse/print -> core serde -> cel bridge both ways ->
Postgres JSONB + SurrealDB object -> the acton REST API, and CEL map
comprehensions (m.all(k,v,...), m.exists(...)) type-check (#104) and
evaluate over it.

Key type is constrained to `string` for this first cut: JSON, Postgres
JSONB, and SurrealDB objects are all string-keyed, so non-string keys
(int/uint/bool) cannot round-trip without lossy string key-encoding. The
type is modeled as `FieldType::Map { key, value }` for forward-compat, but
the DSL parser rejects a non-string key with an actionable error. Non-string
keys are a noted follow-up.

- core: FieldType::Map { key: Box<FieldType>, value: Box<FieldType> }
  (Display = Map<Key, Value>, serde) + DynamicValue::Map(BTreeMap<String,
  DynamicValue>), a NEW variant distinct from Composite. query.rs type-name
  and compat arms.
- dsl: `map` keyword + `<`/`>` tokens; parse_map_type recursively parses key
  and value type expressions and rejects a non-string key with
  DslError::MapKeyNotString; printer round-trips `map<text, V>`.
- cel: bridge DynamicValue::Map -> CelValue::Map (string keys, recursing
  values); check.rs infers/accepts CelType::Map so a map-comprehension
  @require rule type-checks to Bool.
- postgres: JSONB column; bind/read recurse values per V (json_to_map
  decodes datetime/duration/bytes/enum/nested values against the declared
  value type); typed JSONB NULL.
- surrealdb: native string-keyed object; max_size/negative-duration
  validation recursion now walks Map values fail-closed.
- acton: JSON object wire form both directions; request parse validates each
  value against V (mismatch -> 422); schema POST accepts
  {"type":"Map","data":{"value":<FieldType>}}.
- docs: site-guide.md field-types table + a note distinguishing
  map<K,V> (typed, open-keyed, homogeneous values) from composite (fixed
  declared field set) and json (untyped).
Make the value-lattice projection decisions for the three non-obvious field
types explicit, consistent across the bridge and the type-checker, and
documented:

- Enum -> String (the variant name; storage is string-backed, so ordering is
  lexical/by-name). Already bridged; documented and exercised.
- Relation{One} (Ref) -> opaque id String; Relation{Many} (RefArray) ->
  List<String>. Tighten field_accepts so a single ref accepts only a string
  and a many-ref accepts only a list, matching field_type_to_inferred and the
  bridge. Cross-entity dereference stays out of scope (#95).
- File -> metadata map only. A File field is stored as its FileAttachment
  object (DynamicValue::Json) and surfaces as a CEL map; the blob bytes are
  never projected as a value. field_accepts(File) accepts only a map.

Adds a projection table to the bridge module doc and unit tests covering each
projection, including a fail-closed check that no blob bytes leak into a File
projection and stricter relation type-checking.
Update the DSL skill reference to reflect the epic #89 grammar additions:
- duration, bytes(max), and map<text, V> field types (#96/#97/#99) — EBNF,
  field-type sections, lexer keywords, and the backend DDL mapping table.
- @require/@compute/@default CEL rule annotations (#92/#93/#94) — a dedicated
  rule_annotation production, available bindings (fields, now, principal),
  fail-closed semantics, and the @default annotation vs default(...) modifier
  distinction.
- New validation-table rows (MapKeyNotString, CEL parse/type-check errors).
Add a reserved `related.<F>.<col>` namespace usable only inside `@require`
rule expressions. `related.F` dereferences a `Relation{One}` field F to its
committed, tenant-scoped related row, bound as a CEL map; `.col` reads a
column on it. The bare field F remains the opaque id string (#102).

Engine purity is preserved: the CEL evaluator stays 100% pure (no backend
handle, no async, no I/O in `evaluate`; its signature is unchanged). Cross-
entity reads use the same prefetch-and-bind pattern as the request clock
`now` — the route layer resolves the I/O before evaluation and injects a
`related` binding next to `principal`/`now`.

- schema-forge-cel: pure AST walker `related_paths` + `RelatedPath` extract
  every `related.<F>.<…>` path anywhere in an expression.
- schema-forge-dsl: `check_rule_types` rejects at apply-time, with span→line:
  col diagnostics — `related.*` in @compute/@default, a to-many `related.F`,
  and a non-relation/undeclared `related.F`. New `#[non_exhaustive]` DslError
  variants carry the audit-focused messages.
- schema-forge-acton: `check_requires_with_bindings` is the pure core;
  `check_requires_with_related` (route layer) collects distinct Relation{One}
  references, reads each FK, loads the row through the supervised forge actor
  with `inject_tenant_scope` (never the unscoped GetEntity), projects it with
  `dynamic_to_cel`, and binds `related`. Wired into create/update/patch.

Fail-closed: an absent/null FK, a missing related row, or a tenant-hidden row
leaves `related.F` unbound, so the predicate hits an absent reference and is
rejected — never a silent pass or null coercion. Multi-hop
(`related.F.G.<…>` crossing a second relation) is rejected at runtime with a
clear message; the resolver has every target schema via the batch fetch.

Tests: 12 cel walker units, 6 dsl apply-time units, 7 acton surrealdb
integration tests (pass/reject, null FK, missing row, cross-tenant isolation,
to-many, multi-hop). Docs: new section in docs/rule-ordering-reference.md.

Minor version bumps: cel 0.8.0→0.9.0, dsl 0.11.0→0.12.0, acton 0.33.0→0.34.0.
…s engine

Sweep the skill references for staleness after epic #89:

- SKILL.md: add the CEL rule annotations (@require/@compute/@default) and the
  duration/bytes/map field types to the frontmatter, When-to-Use, quick-ref
  tables, and a new "Write-Time Rules" section; add the schema-forge-cel crate
  and refresh crate versions; correct the now-dispatched before_validate event.
- dsl-reference.md: document the related.<F>.<col> single-hop cross-entity read
  primitive (#95) — limits, tenant-scope, fail-closed.
- hooks-reference.md: before_validate is now wired (was reserved); add a
  rules-vs-hooks guidance note (prefer @require for in-process validation).
- patterns.md: new Write-Time Rules pattern (rules vs hooks, bindings,
  cross-entity reads, gotchas).
- query-api-reference.md: add Duration (Go-style) and Bytes (base64) filter
  coercion rows; note Map/Json/relation/File have no scalar coercion.
- examples.md: add a worked example using rules + duration/bytes/map.
- storage-reference.md: cross-reference the bytes field type vs file fields.

All grammar/semantics grounded in the parser/codegen, not the issue shorthand;
rule annotations correctly shown as field-level (not schema-level).
The installed skill (~/.claude/skills/schemaforge/) carried five reference
files the repo never tracked: cli-reference, config-reference, dsl-quickref,
rest-api-reference, signing-reference. Import them so the repo is the single
source of truth, and apply the epic #89 staleness sweep to them:

- dsl-quickref.md: add duration/bytes/map field types and @require/@compute/
  @default annotations to the quick-ref tables, add a Write-Time Rules section,
  correct the now-dispatched before_validate event.
- signing-reference.md: note that rule-annotation text (@require/@compute/
  @default) is covered by the schema signature — tampering a predicate fails
  verify under enforce (#106).
- rest-api-reference.md: document the write-time rule phases on entity
  create/update (422 on @require rejection; server-derived @compute/@default).
- cli-reference.md: parse syntax-validates rule expressions; apply type-checks
  them against field types and validates related.* cross-entity references.
- config-reference.md: imported unchanged (the rules engine has no config).
@rrrodzilla rrrodzilla merged commit fd3b828 into main May 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant