(Entity-Rule Definition Language · 实体规则定义语言)
Status: v2.3 · beta Date: 2026-10-08 Version semantics: this document (the ERDL language specification) is version v2.3; the top-level
protocol: "erdl/v2"(protocol identifier, fixed value) andversion: "2.2.0"(rule-format version) are independent version identifiers, not to be conflated with the document version (this v2.3 change does not alter the rule format, soversionstays 2.2.0). Version strategy: Breaking changes are limited to document major versions: a rule-format breaking change is limited to aversionmajor (e.g. 2.2.0 → 3.0.0); a DO hash-preimage schema breaking change (e.g. v2.3 addingcontext) is explicitly identified byeval_profile.spec_versioninside the DO (§8.2a.1b), by which verifiers select the recomputation schema — and does not violate the rule-format non-breaking promise of §2.3. Author: Tang Qixin(唐启鑫) Trademark: ERDL™ is a trademark of Shenzhen Miaojing Technology Co., Ltd. Positioning: ERDL (Entity-Rule Definition Language) is a declarative rule definition format, carried in YAML/JSON, for precisely expressing entity structures and behavior rules. This specification is independent and neutral — it defines only the format itself, depending on no particular implementation or upper-layer framework; its deterministic evaluation and canonical form support byte-for-byte cross-implementation verification. In ERDL, rules decide everything: rules are the carrier of semantics, the boundary of execution, the evidence of audit, and the context of governance. Conformance language: MUST / MUST NOT / SHOULD / SHOULD NOT / MAY in this document are interpreted per [RFC 2119]. The Chinese edition annotates normative wording bilingually — e.g. 「必须(MUST)」「不得(MUST NOT)」 — where the parenthesized RFC 2119 keyword is the normative authority; unannotated Chinese modal words (「不得」「禁止」「必须」「须」) are stylistic, and their normative level follows the RFC 2119 keyword of the corresponding sentence in this English edition.
ERDL (Entity-Rule Definition Language) is a declarative rule definition format carried in YAML/JSON, for precisely expressing entity structures and behavior rules. It contains a rule declaration (Rule, defining a when → then decision) and a preset entity namespace (Entity, e.g. agent/tool/task — a semantic namespace convention for field references, not a top-level declaration; see §3). Entity defines the structure of an object; Rule prescribes the consequence of a condition; together they form an executable, reviewable, and verifiable rule expression.
ERDL is a “multi-party semantic layer” — it is not just a rule format, but a semantic convention layer shared by human, LLM, system, and audit:
| Party | ERDL’s role |
|---|---|
| Human (business/domain expert) | the precise translation of natural-language rules, readable and reviewable |
| LLM (general model) | structured input that removes ambiguity and supports deterministic evaluation |
| System (rule engine) | standardized rule description; fn delegation controls the call boundary |
| Audit (regulation/compliance) | a traceable rule record clearer than code + natural language |
In this semantic layer, rules decide everything: humans express intent, the LLM translates semantics, the system executes decisions, and audit reviews the evidence. ERDL’s deterministic semantic layer gives an LLM clear direction: a user describes a rule in natural language; after ERDL precisely translates it, the LLM deterministically executes it per structured semantics — the conversational interface is the unified entry point. (Informative claim, with premise): this specification’s determinism guarantee covers only the post-translation evaluation layer (§7 E1–E12); the natural-language → ERDL translation itself depends on the LLM and is a non-deterministic process, outside the byte-for-byte “same input ⇒ same output” commitment.
The core challenge of AI governance is not whether a model can answer, but how probabilistic outputs satisfy the deterministic boundary required by regulation, auditing, and accountability. ERDL encodes declarative rules in YAML/JSON, codifying entity structures, behavioral constraints, and disposition actions into a verifiable deterministic rule layer, and turns “how an AI should act” from a training assumption into an inspectable object.
From trust to verification: Traditional governance relies on alignment assessments or post hoc explanations, making it difficult to prove which constraints a specific decision followed. ERDL expresses constraints as when → then rules. Each evaluation is bound to a canonical_tree snapshot and an outcome hash, enabling independent recomputation and byte-for-byte verification across implementations. Governance stakeholders no longer merely trust that a model has been “properly trained”; they can verify whether an action matched a rule, which rule it matched, and why that result was produced.
Accountable evidence chain: ERDL evaluation results are hashable and recomputable. Rule versions, input snapshots, decision outputs, and hashes can be anchored together to form audit records. Auditing is not a restatement of logs, but a reproducible proof process — why an action was allowed and why it was blocked can be traced and verified, preventing post hoc explanations from becoming the sole basis. “Who approved it” is verifiable only when the actor identity enters the chain with authentication evidence (§6a.5.4 event-authentication evidence) — a bare actor string is not a verifiable approval proof (see §6a.5.4), so the traceability of “who approved” is premised on authentication evidence, not an unconditional format property.
Mechanized human–machine accountability boundary: ERDL can use decision types such as REQUEST_HUMAN and ESCALATE to require human adjudication for high-risk operations, sensitive entity changes, irreversible actions, and similar cases. Final human decision-making authority no longer depends on procedural slogans; it is encoded as executable, testable, and auditable rule paths.
Compliance as code and cross-organizational mutual recognition: Regulations, platform policies, and internal red lines can be translated into ERDL rules and continuously validated through test vectors. Different organizations, implementations, or third-party auditors can independently recompute outcomes from the same rules and canonical_tree, establishing a “trust but verify” governance paradigm and turning compliance from declarative documentation into governance facts that are executable, inspectable, and mutually recognizable.
The semantic carrier is the kernel, not the syntax. Same canonical tree ⇒ same hash (one-directional commitment). Same (rule set, context, evaluation options, state) ⇒ same decision and DO. Equivalent semantics do not guarantee the same tree: e.g.
a AND bandb AND aare semantically equivalent but differ in tree structure and hash. Determinism guarantees “same input ⇒ same output”, not “equivalent input ⇒ same tree” — the latter does not hold and is not part of this commitment.
Any scheme that takes “operator syntax” as its semantic carrier is forced to expand operators linearly as new requirements appear, so its cost never converges. This specification therefore converges semantics onto a single kernel (the expression tree), and treats the multiple writing forms as deterministic projections of that kernel — they are not independent languages, but different views of the same semantics. Rules decide everything: a rule’s validity depends not on its writing entry point or implementation form, but on canonicalized semantics that are unique, recomputable, hashable, and byte-for-byte verifiable.
An ERDL document (*.erdl.yaml) consists of six top-level fields — four MUST and two MAY — whose order MUST be fixed:
protocol: "erdl/v2" # protocol identifier, fixed value
version: "2.2.0" # rule-format version
metadata: { ... } # document-level metadata (see §2.2)
state: [ ... ] # state space declaration (optional, see §6a)
transitions: [ ... ] # state transition rules (optional, see §6a)
rules: [ ... ] # rule list (see §4)
| Top-level field | Type | Required | Description |
|---|---|---|---|
protocol |
string | MUST | Protocol identifier, fixed value "erdl/v2" |
version |
string | MUST | Rule-format version (semantic versioning) |
metadata |
object | MUST | Document-level metadata |
state |
array | MAY | State space declaration (§6a) |
transitions |
array | MAY | State transition rules (§6a) |
rules |
array | MUST | Rule list; elements defined in §4 |
metadata:
name: "my-first-rule-set"
description: "Allow file read operations"
category: coding
decision: ALLOW # fallback decision when no rule matches
tags: [example]
| Field | Type | Description |
|---|---|---|
name |
string | Rule set name |
description |
string | Rule set description |
category |
string | Rule set category (coding/security/compliance…) |
decision |
string | Fallback decision (default verdict when no rule matches; see §6) |
tags |
array | Tags |
protocol: "erdl/v2";protocol major version, new constraints SHOULD be non-breaking for existing rules (a Warning at load, not an Error); a cross-major-version change (e.g. erdl/v1 → erdl/v2) is a breaking change and is not covered by the backward-compatibility promise. Exception: when:"true" combined with a blocking then is rejected at load in any version (Error). This non-breaking promise constrains rule-format compatibility only (existing rules still load, evaluate, and produce the same decision); the DO hash-preimage schema evolves independently, identified by eval_profile.spec_version (§8.2a.1b).An ERDL document moves from file to decision result through a fixed five-step pipeline. Understanding this pipeline is understanding how ERDL is “parsed” and “evaluated”:
| Step | Action | Input → Output | Basis |
|---|---|---|---|
| ① Load | Read the rule document | *.erdl.yaml → structured object |
§2.1–§2.3 |
| ② Validate | Load-time type checking | structured object → valid document (reject invalid) | E5, §6a.2/§6a.4/§6a.7 |
| ③ Compile | Normalize the three writing forms | valid document → expression tree (canonical_tree) | E7, §8.2 |
| ④ Evaluate | Judge the tree node-by-node against the context | expression tree + context → decision | §7 |
| ⑤ Emit | Produce evaluation evidence | decision → hashable, recomputable result | E6, §8 |
*.erdl.yaml and parse it per the §2.3 format conventions (YAML and JSON are equivalent, losslessly interchangeable).when/expr mutual exclusion, state-block validation (§6a.2/§6a.4/§6a.7), etc.; violations are rejected at load.within/rate is injected under control via temporal_state, and §6a authority state via state.*.The full contracts for the input context and the evaluation output are in §7.0.
An Entity is the subject a rule acts upon (passed via context). ERDL predefines the following Entity types:
| Entity type | Description |
|---|---|
agent |
A single Agent instance |
tool |
A tool invoked by the Agent |
task |
A task executed by the Agent |
workflow |
A multi-Agent orchestration process |
human |
A human approver |
guardian |
Supervisor |
Field references in rules (e.g. tool.name, context.amount) use Entities as their semantic namespace. Field paths are load-bearing: a field name is frozen once published ([FREEZE-1]), and aliases MUST be normalized to the canonical name first.
Field-path grammar (MUST): a field reference path is a dot-separated segment sequence (a.b.c), each segment a snake_case identifier; $ is the root reference, $.path a root dot path; state.*/event.* are controlled namespaces (§6a.3/§6a.7). Path resolution follows the context object’s key path segment-by-segment (§7.0.1); array subscripts and dotted keys MUST use the deterministic dot-split (implementations MUST fix their path grammar in the document, avoiding divergence on dotted keys / array access).
Rule is the core unit of ERDL: Rule = Metadata + When (condition) + Then (action) + Audit (audit). When compiles to an expression tree (§5), Then is a decision type (§6), and Audit records the audit information of the rule’s evaluation.
The rules[] sub-field order MUST be fixed: name → description → category → priority → override → ring → tier → enabled → when → gloss → then → message → instruction → correction → unless → explanation → alternative → legal_basis → source_text.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | MUST | Unique rule identifier, format [CAT]-[NNN]-description |
description |
string | MUST | Human-readable description |
category |
string | MAY | Rule-level category; defaults to metadata.category (see §2.2), allows mixed categories within one document |
priority |
integer | MUST | Smaller number = higher precedence (see §7.1) |
override |
string | SHOULD | Override level: critical > high > normal > low (default normal) |
ring |
integer | SHOULD | Execution ring: 0 kernel / 1 recovery / 2 approval / 3 advisory (default 3 advisory) |
tier |
integer | MAY | Rule tier 0–5 (0–2 safety baseline MUST use Simple, ≥3 business scope may use Expression); tier governs the writing form only, not the evaluation-error fold (see E12); absent = undeclared (never locked, see §7.1a) |
enabled |
boolean | MAY | Rule enable flag (default true); false skips the rule during evaluation |
when |
object / string | MUST | Trigger condition (see §5); the string form is only the catch-all literal "true" (unconditional match, compiled to a literal true node, §7.0.2 determination); a decision-table default row when: [] is the §5.4 rows[] row-level sub-field (compiled to a literal true), not a third form of this top-level field |
gloss |
string | MUST | Natural-language readable projection rendered by the engine from the when tree (§5.5); lint checks gloss == render(tree), hand-writing forbidden; does not enter the hash (G4) |
then |
string | MUST | Decision type (see §6) |
message |
string | SHOULD | Decision message (blocking then MUST be non-empty) |
instruction |
string | MAY | Advisory instruction (for the ALLOW + instruction case) |
correction |
string | MAY | Correction text (CORRECT decision; source of the evaluation output primary_correction, see §7.0.3) |
unless |
object/null | MAY | Exemption condition block (optional) |
explanation |
string / object | MAY | Bilingual explanation (why the rule exists and what harm it prevents) |
alternative |
string / object | MAY | Suggested alternative action when blocked |
legal_basis |
string | MAY | Legal basis (citation of the regulation clause) |
source_text |
string | MAY | Excerpt of the original regulation text |
protocol: "erdl/v2"
version: "2.2.0"
metadata:
name: "my-first-rule-set"
description: "Allow file read operations"
category: coding
decision: ALLOW
tags: [example]
rules:
- name: "COD-001-allow-read"
description: "Allow all read_file tool calls"
priority: 10
override: high
ring: 3
when:
logic: AND
conditions:
- field: "tool.name"
operator: eq
value: "read_file"
gloss: "tool.name equals read_file" # engine-generated (G2), lint-enforced
then: ALLOW
message: "read_file call allowed"
unless: null
when Condition Expressionwhen is the rule’s embedded condition expression. ERDL provides three writing forms (Simple / Expression / Decision Table), any of which compiles and normalizes to the same semantic kernel (the expression tree) and can be rendered back into any form; there is additionally a readable projection gloss (§5.5) deterministically generated from the tree as natural language.
| Projection | Carries | Applicable tier | Description |
|---|---|---|---|
| A · Simple | 30 operators | 0–2 (MUST) | Safety baseline, the most common writing form |
| B · Expression | full 34-node tree | ≥3 | Business panorama: logic/quantifiers/arithmetic/time/aggregation |
| C · Decision Table | matrix | — | Preferred by business/finance staff; compiles to the same kernel |
The three forms share the same semantics, differing only in expressiveness and tier authorization. tier is a rule hierarchy level (0–5), ordered low-to-high by the rule’s constraint strength and scope — in this document, tier 0–2 is the safety baseline (MUST use Simple), and tier ≥3 is the business panorama (Expression may be used). when and expr MUST NOT coexist (E5).
Simple is the preserved, existing set of semantic units — 30 operators = 28 conditions + 2 modifiers, unchanged. It corresponds to system safety rules (tier 0–2).
| Family | Count | Operators |
|---|---|---|
| Comparison | 6 | eq · ne · gt · gte · lt · lte |
| List | 2 | in · not_in |
| String | 5 | contains · not_contains · match · starts_with · ends_with |
| Boundary negation | 2 | not_starts_with · not_ends_with |
| Existence | 2 | exists · not_exists |
| Length | 5 | length_gt · length_gte · length_lt · length_lte · length_eq |
| Range | 2 | between · not_between |
| Count | 4 | count_gt · count_gte · count_lt · count_lte |
| Modifier | 2 | within (time window) · rate (rate limit) |
"100" gt 50 is always false;"2" gt "10" is true); cross-type returns false;casefold normalization node (extension profile, see §5.3.3);[min,max] supports only numbers; non-numeric returns false;in_set{ref,digest} node for external reference (versioned hash reference; the list does not enter the tree and is not subject to the 256 inline cap, see §5.3.3);matches → match, neq → ne. Aliases are not new operators (still the 30-operator set); the canonical form MUST use the canonical name, and aliases never enter the tree or the hash. Not implementing aliases is still conformant.Authoritative compile mapping: all 30 operators have a definite compile target, with none dangling — 13 direct nodes (eq/ne/gt/gte/lt/lte·in·contains/starts_with/ends_with/match·exists·between), 6 not-derived (not_in/not_contains/not_starts_with/not_ends_with/not_exists/not_between), 9 length/count compositions (length_* 5 + count_* 4), 2 time modifiers (within/rate).
| # | Simple operator | Compile target | Expression tree form |
|---|---|---|---|
| 1-6 | eq ne gt gte lt lte |
direct node | comparison node |
| 7 | in |
direct node | set node in |
| 8 | not_in |
not + exists guard | exists(field) AND not(in(...)) |
| 9-12 | contains starts_with ends_with match |
direct node | string node |
| 13 | not_contains |
not + exists guard | exists(field) AND not(contains(...)) |
| 14-15 | not_starts_with not_ends_with |
not + exists guard | exists(field) AND not(...) |
| 16 | exists |
direct node | existence node (value non-null and non-undefined; empty string "", 0, false all count as “existing” — “existing” ≠ “non-empty string”) |
| 17 | not_exists |
not composition | not(exists(...)) |
| 18-22 | length_gt/gte/lt/lte/eq |
composition + exists guard | exists(field) AND length(field) compare n |
| 23 | between |
direct node | range node (numeric only) |
| 24 | not_between |
not + exists guard | exists(field) AND not(between(...)) |
| 25-28 | count_gt/gte/lt/lte |
composition + exists guard | exists(field) AND aggregate(count(...)) compare n |
| 29 | within |
time modifier | time window (as_of injected by engine) |
| 30 | rate |
time modifier + aggregate | rate limit (temporal_state) |
exists guard (compile-layer guarantee of E11 null propagation): the not_* operators (except not_exists) and the length_*/count_* compositions MUST compile to exists(field) AND <derived expression>, not a bare not(positive operator) or a bare length/count(...) comparison. Reason: a positive operator returns false for a missing field and length(missing) returns 0; a direct not flip or numeric comparison would break null propagation (fail-open). not_exists is the sole exception — its semantics are “perceive field missing”, so it stays a bare not(exists(...)).
Stateful operators (within/rate): within and rate are the only two stateful operators, whose evaluation depends on sliding-window counts across decisions. This state is not stored in the expression tree node but is maintained by a separate Guard state manager, entering the audit record as the temporal_state field — the expression tree itself remains a pure function (E1 holds), while the state source is auditable and recomputable.
Stateful operator truth semantics (MUST): count reaches threshold → condition holds (trigger); below threshold → record this event and return false (allow).
| Operator | Threshold | First/under-limit | After threshold |
|---|---|---|---|
rate: "N/window" |
N | first N times: record + false | from the (N+1)-th time: true |
within: "window" |
1 | first: record + false | from the 2nd time in window: true (dedup) |
Supporting constraints (all MUST): ① post-counting (count only when the positive condition holds); ② count isolation key (within keys on field+operator+value+scope, rate keys on field+operator+value+rate+scope; scope is the counting-subject scope — which field to group by (e.g. user.id/tool.name), avoiding a global counter shared across distinct subjects; default is global, ungrouped); ③ record timing in the “under-limit” branch.
Expression opens the kernel’s full expressive power for complex business rules (tier ≥3). The semantic kernel is a typed expression tree of 34 nodes (in 10 groups), frozen at [FREEZE-2]:
| Group | Nodes | Semantic capability |
|---|---|---|
| Value | field · var · literal | reference fields, context variables, constants (var only $/$.path; MUST NOT read clock or randomness) |
| Logic | and · or · not | composition |
| Comparison | eq · ne · gt · gte · lt · lte | operands may be field, variable, literal, or arithmetic subtree |
| Set | in | scalar ∈ set |
| String | contains · match · starts_with · ends_with | pattern matching; match uses safe regex |
| Existence/measure | exists · length · between | existence, length (Unicode code points), closed interval |
| Quantifier | all · any · none | per-element array judgment; empty array always false |
| Arithmetic | add · sub · mul · div · round | fixed-point deterministic arithmetic |
| Time | days_between · epoch_ms · date_add · date_part · month_last_day | date difference, timestamp, date arithmetic, component extraction, last day of month |
| Aggregate | aggregate (count/sum/avg/min/max) | array aggregation |
Node total: Value 3 + Logic 3 + Comparison 6 + Set 1 + String 4 + Existence/measure 3 + Quantifier 3 + Arithmetic 5 + Time 5 + Aggregate 1 = 34. The 6 comparison operators, 4 string operators, 5 arithmetic operators, 3 quantifier kinds, and 5 aggregate functions are carried by parameterized node types in implementations (e.g.
compare{op},string{op},arith{op},quantifier{kind},aggregate{fn}), so the “34 semantic nodes” map to fewer type literals in code — this is the relationship between semantic nodes and type projections, not a count contradiction.
String-normalization extension nodes (extension profile): beyond the frozen 34-node kernel, the kernel extension profile provides string-normalization nodes (unary
string_transform, 3 operators):casefold(Unicode simple case folding; deterministic, locale-independent),trim(strip leading/trailing Unicode whitespace), andpath_normalize(POSIX lexical path normalization: unify\//separators, collapse repeated separators, lexically resolve./.., preserve a leading/). All three are pure string transforms (string → string); a non-string input returnsnullwith atype_mismatchwarning (safe fold). They are not part of the frozen 34-node kernel (same positioning as the fn node), but have a fixed S-expression shape (§8.2.1) entering the hash.
External-list node (extension profile):
in_set{ref,digest}(the external-list variant ofin, unaryin_set) — membership judgment over large lists (e.g. AML country/sanction lists >256 items), not part of the frozen 34-node kernel.refis the list reference identifier,digestis the versioned hash of the list content (sha256:hex(JCS(values))); the list body is injected by the evaluation context (resolveSet(ref)), does not enter the expression tree, and is not subject toin’s 256 inline cap; a digest mismatch or unregisteredreffails closed (not_ruleable). Membership semantics are identical toin(string NFC comparison, see §7.3(a)), with a fixed S-expression shape (§8.2.1) entering the hash.
Expression writing example:
when:
expr:
lt:
- div:
- sub: [{ field: "tool.args.price" }, { field: "context.cost" }]
- field: "tool.args.price"
- 0.15
The decision table faces business and finance staff, expressing multi-condition combinations in row-column structure, compiled to the same kernel:
kind: decision_table
columns:
- field: "context.amount"
label: "application amount"
rows:
- when: [["gte", 10000]]
then: "REQUEST_HUMAN"
priority: 1
- when: [["gte", 5000]]
then: "ESCALATE"
priority: 2
- when: [] # default row (unconditional, fallback)
then: "ALLOW"
priority: 3
Compile rules (E7): ① each row’s when condition group compiles to logical AND in field-column order, and each condition unit compiles to a comparison node; ② row order is precedence (first match from top, consistent with priority; the two MUST NOT conflict); ③ the default row when: [] compiles to literal true; ④ then MUST belong to the §6 decision type enumeration; ⑤ the compiled tree is identical to a hand-written Simple/Expression tree.
A decision table is syntactic sugar for rules[] (MUST): the table expands row-by-row into rules — one rule per row (then maps to each rule’s action.decision), with priority/ring/override/gloss/message attributed per row; the expanded rule set is semantically equivalent to a hand-written rules[] (E7). Row order is precedence, consistent with priority; when priority is absent it is auto-numbered by row order (1, 2, 3, …), and the default row (when: []) MUST be last with the largest priority.
gloss is natural-language text deterministically generated from the tree:
gloss: "when (sale price minus cost) divided by sale price is less than 15%, human approval is required" # engine-generated, lint-enforced
Five invariants (all MUST):
| # | Invariant |
|---|---|
| G1 | gloss = render(tree): deterministically generated by a frozen rendering template |
| G2 | every rule and every transition rule MUST carry gloss; lint checks gloss == render(tree); hand-writing forbidden |
| G3 | gloss MUST use the Entity’s display_name when a field contract exists (bilingual); falls back to the raw field path when no contract exists (except event.* payload keys, see §5.5) — so the lint check gloss == render(tree) remains reproducible without a contract |
| G4 | gloss is a render product (does not enter the hash); displayed via live render(tree) |
| G5 | Simple rules also generate gloss (rendered after compiling to a tree) — the reading layer is uniform |
gloss rendering templates (per node, English as canonical; {A}/{B}/{C} are recursive render results of sub-expressions):
| Node | English template |
|---|---|
field |
{field} |
var |
$ or path |
literal |
{value} |
and |
{A} and {B} |
or |
{A} or {B} |
not |
not ({A}) |
eq |
{A} equals {B} |
ne |
{A} does not equal {B} |
gt |
{A} is greater than {B} |
gte |
{A} is greater than or equal to {B} |
lt |
{A} is less than {B} |
lte |
{A} is less than or equal to {B} |
in |
{A} in {B} |
contains |
{A} contains {B} |
match |
{A} matches {B} |
starts_with |
{A} starts with {B} |
ends_with |
{A} ends with {B} |
exists |
{A} exists |
length |
length of {A} |
between |
{A} is in the inclusive range {B} to {C} |
all |
all elements in {A} satisfy "{B}" |
any |
at least one element in {A} satisfy "{B}" |
none |
no elements in {A} satisfy "{B}" |
add |
{A} plus {B} |
sub |
{A} minus {B} |
mul |
{A} times {B} |
div |
{A} divided by {B} |
round |
{A} rounded |
days_between |
days between {A} and {B} |
epoch_ms |
epoch ms of {A} |
date_add |
{A} plus {B} {unit} |
date_part |
{part} of {A} |
month_last_day |
the last day of the month of {A} |
aggregate(count) |
count of {A} |
aggregate(sum) |
sum of {A} |
aggregate(avg) |
average of {A} |
aggregate(min) |
minimum of {A} |
aggregate(max) |
maximum of {A} |
state.* gloss rendering (G3): a field node whose path’s first segment is
staterenders thestate.<name>display_name(en, G3, §6a.1), falling back to the variable name; theexistsboolean special case does not apply to state fields (state enum values are non-boolean;exists(state.x)is always “variable declared and always has a value”, independent of the enum value).
event.* gloss rendering (G3): a field node whose path’s first segment is
event(only intransitions[].when, §6a.7) renders its four reserved fields as fixed English readable names:event.event_id→ event id,event.on→ event name,event.actor→ event actor,event.at→ event time; a payload keyevent.<key>has no display_name and renders as the bare key name.
existsrendering (MUST):existsuniformly renders as{A} is present— MUST NOT special-caseis_*/has_*fields as “is true”: the semantics ofexistsis “the field is present” (§5.2.3, false also counts as present), and rendering “is true” would misleadexists(has_consent)(value false) as “has_consent is true”.
Gloss rendering details (must be reproduced exactly across implementations):
not(eq({A},{B}))normalizes to thenetemplate ({A} does not equal {B}), not a literalnot ({A} equals {B})nesting;- string literals render quoted (
"rm"), and list literals render their string members quoted (["a", "b"]);- arithmetic nodes (
add/sub/mul/div) render parenthesized ((a plus b)) to preserve operator precedence in the natural-language reading.
then Decision TypesThe value of then MUST belong to the following 13 decision types:
| # | Decision type | Semantics |
|---|---|---|
| 1 | ALLOW | allow |
| 2 | DENY | block |
| 3 | CORRECT | correct deviation |
| 4 | NOTIFY | notify |
| 5 | REQUEST_HUMAN | request human adjudication |
| 6 | ESCALATE | escalate |
| 7 | DELEGATE | delegate |
| 8 | DEFER | defer |
| 9 | EMERGENCY_HALT | emergency stop |
| 10 | ROLLBACK | roll back |
| 11 | QUARANTINE | quarantine |
| 12 | WORKFLOW | workflow (state machine; substates WORKFLOW_WAITING / WORKFLOW_PROGRESS; note: distinct from the §6a authority state machine) |
| 13 | GUIDE | guide |
Why 13 decision types (design rationale):
Traditional IT rule engines and access control are binary — ALLOW or DENY. They assume the decider is “the system”, which only needs to answer “allow or deny”.
But in the AI era, the thing being decided on is an understanding but not fully reliable LLM. Faced with an LLM’s output, a simple “deny” is giving up — it discards all the work the LLM has done and forfeits the chance to “correct once and keep going”. This specification’s design principle is: deterministic rules exist not to reject the LLM, but to maximize the LLM’s value while keeping the safety floor.
The 13 decision types unfold that principle into five groups:
| Group | Decision types | Meaning |
|---|---|---|
| Allow vs. block | ALLOW / DENY | the binary floor — allow when clearly safe, block when clearly out of bounds |
| Guide, don’t abandon | CORRECT / GUIDE | when the LLM output deviates, correct or guide it back, rather than discarding the whole output |
| Human-in-the-loop | REQUEST_HUMAN / ESCALATE / DELEGATE / DEFER | when uncertain, bring in human adjudication, escalation, delegation, or deferral — hand “what the machine can’t resolve” to “people or process” |
| Safety fallback | EMERGENCY_HALT / ROLLBACK / QUARANTINE | intervene decisively on danger — halt, roll back committed side effects, quarantine suspicious objects |
| Process | NOTIFY / WORKFLOW | notify (record without blocking), workflow (enter a multi-step state machine) |
In one line: traditional IT asks “allow or deny”; ERDL asks “how to let the LLM do better under control”. DENY is the last resort, not the only resort.
Session-level state — delegated authority, revocation, approval flows — needs to be declared explicitly. This section defines the state space (state) and state transition rules (transitions), generalizing the §5.2 within/rate “tree-external counter state” into named authority state. The state body is held by the engine (outside the expression-tree kernel); the expression-tree kernel (34 nodes) gains no new nodes — state is read-only injected via state.*.
Hard boundary: the state layer is a finite-state machine (FSM), not a Turing machine. The state space MUST be finite (enums + resource caps); arbitrary-length history, recursion, and pushdown stacks are rejected. Anything beyond FSM expressiveness (event sourcing) does not enter the engine.
Layering boundary (single instance): a state variable is document-instance single-key — one state variable holds a single value within the current ERDL document instance. Per-entity / per-authorization-relation multi-instance state (e.g. the P→A grant and the A→B grant each need their own state) is carried by the organization layer instantiating one document per relation — outside this section’s scope (layering: the organization layer consumes ERDL primitives; this state block is a single-instance FSM primitive).
state:
- name: authorization # state variable name, unique within the document
values: [authorized, revoked] # enum values (2–4 strings, unique within the list)
initial: authorized # initial state (MUST belong to values)
display_name: { zh: 授权状态, en: authorization state } # optional, gloss-readable name (G3)
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | MUST | State variable name: non-empty ASCII identifier ([A-Za-z_][A-Za-z0-9_]*), MUST NOT contain . (avoids field dot-path parsing collision; ASCII-only makes key order identical under UTF-8 code-point and UTF-16 code-unit orderings), MUST NOT be the reserved word state, unique within the document; state.<name> namespace |
values |
array | MUST | Enum value list: 2–4 non-empty ASCII-identifier strings, MUST NOT contain ., values MUST be unique; strings MUST be NFC-normalized (E10) before comparison |
initial |
string | MUST | Initial state, MUST be one of values |
display_name |
object | MAY | Bilingual readable name { zh, en } (same as Entity convention, G3); gloss rendering of state.<name> uses the en value, falling back to name |
A state variable is initialized to
initialat document load and always has a value — there is no “runtime missing” state. To express “authorization not yet established” (unavailable), the author MUST declare an explicit sentinel value (e.g.unestablished) invaluesand set it asinitial, rather than relying on null propagation.
A transition rule is the FSM’s F (transition function) — deterministic, triggered only by an event:
The transitions[] sub-field order MUST be fixed: on → name → audit_as → reason → enabled → when → gloss → set (name is an optional field, MAY, same format as §4.1; gloss is a render product, MUST, see the gloss clause below).
transitions:
- on: revoke # triggering event name (the sole controlled-injection entry)
name: "TR-001-revoke" # optional, rule identifier (format per §4.1)
audit_as: DELEGATE # audit carrier only (does not participate in evaluation / short-circuit)
reason: revoke # transition semantic tag (distinguishes delegation vs revocation)
enabled: true # enable flag (default true)
when: { ... } # optional guard (expression tree; reads only state.* and event.*, not free context)
set:
authorization: revoked # state transition: state.authorization ← revoked
| Field | Type | Required | Description |
|---|---|---|---|
on |
string | MUST | Triggering event name (controlled injection, see §6a.3); MUST match [a-z][a-z0-9_]{0,31} (event names enter audit and hash; free Unicode adds normalization burden) |
name |
string | MAY | Transition rule identifier (format per §4.1’s [CAT]-[NNN]-description); when absent, error attribution uses (on, reason, definition ordinal) |
audit_as |
string | MAY | Audit classification label (audit-only, see §6a.5): defaults to NOTIFY; value narrowed to {ALLOW, NOTIFY, DELEGATE, ESCALATE, REQUEST_HUMAN} (borrows the then vocabulary, but its semantics is audit classification, not an evaluation decision — this field does not participate in §7.0.2 evaluation and triggers no short-circuit), MUST NOT be a restrictive type (DENY/EMERGENCY_HALT etc.) |
reason |
string | SHOULD | Transition semantic tag (e.g. revoke); MUST match [a-z][a-z0-9_]{0,31}, SHOULD be unique within the document (lint) |
enabled |
boolean | MAY | Enable flag (default true); when false the transition rule does not participate in event handling (gray-release / emergency disable without editing the document) |
when |
object | MAY | Guard condition (compiled to expression tree; reads only state.* and event.*, MUST NOT read free context, see §6a.7) |
gloss |
string | MUST | Natural-language readable projection rendered by the engine from the when tree (§5.5); lint checks gloss == render(tree), hand-writing forbidden; does not enter the hash (G4) |
set |
object | MUST | State transition mapping; keys are state variable names, values MUST be one of that variable’s values |
transitions gloss (G2, MUST): like
rules[].when, thetransitions[].whenguard expression tree is rendered to gloss by the engine (§5.5), with lint verifyinggloss == render(tree)and forbidding hand-writing;state.*/event.*rendering is per §5.5. A transition rule’s gloss serves audit readability only (not in the hash, same as G4) and does not change evaluation semantics.
on event is an atomic transaction — guards are evaluated one by one in transitions definition order (against the same pre-event snapshot); evaluation stops at the first EvaluationError, no set is applied (fail-closed), a transition_error audit event is recorded with its error taken from that (first-failing) rule, errored=true follows E3; type_mismatch-class warnings are handled only per §7.3(a) folding semantics (errored=false, not an error, and do not stop evaluation), but are not recorded — transition audit records have no warnings field (§6a.5); only after all guards pass is the full set of sets committed at once.transitions[].when MUST NOT use within/rate (load-time Error) — transition evaluation has no side-effect counting, consistent with the purity of “transitions do not count”, avoiding the ambiguity of “does an event count once?”.transitions[].when node set MUST be: Simple condition operators (the 28 condition operators of §5.2) + logic nodes (and/or/not) + time nodes (epoch_ms/days_between/date_add/date_part/month_last_day, for freshness time comparison, §6a.7) + field/literal; MUST NOT use: quantifiers (all/any/none), arithmetic (add/sub/mul/div/round), aggregates (count/sum/avg/min/max), fn (function delegation), within/rate (stateful operators). Of these, fn is already outside the transition-guard compilable range (Grade C fallback, not kernel); this prohibition is an explicit defense, preventing implementers from mistakenly introducing fn into guards. Resource cap per the E4 Grade A quota (arith depth ≤2 / tree depth ≤6 / nodes ≤64); transition guard evaluation errors do not apply the E12 per-tier fold — they always follow §6a.2’s atomic fail-closed (EvaluationError → no set committed, §6a.5).state.* inside when reads the state snapshot at event-arrival time (i.e. before any of this event’s transitions take effect), not the post-transition intermediate state — keeping the guard decision decoupled from the transition result and deterministic.error records the first failing rule), so definition order is hash semantics — implementations MUST NOT depend on definition order to change the set result, but MUST attribute the error by definition order.evaluate) — the two are separated so evaluation has no side effects.evaluate(); evaluate() and event handling are mutually exclusive under the instance lock, so the state.* that evaluate() reads at start is necessarily the state after all arrived events have committed;event_id is processed only once, the duplicate is dropped and logged off-chain (not in the hash chain);injectEvent returns an explicit receipt — committed (a transition committed), noop (no matching transition or an unsatisfied guard; no state change, no audit record, no state_version increment), rejected (over-limit payload / duplicate event_id / auth failure / guard error; recorded chain-externally). The caller MUST judge by this, MUST NOT mistake a noop for “revoked” — a revoke-semantics noop MUST write a chain-external alert record or a chain summary record, so operators do not wrongly believe the revocation happened.enabled transition rules that set the same state variable to different values under the same (on, state variable) constitute a determinism conflict. The loader MUST complete the following deterministic check, rejecting rather than silently accepting (rules set to the same value are idempotent, no conflict, exempt):
when omitted, or whose compiled product is a literal true node — implementations MUST NOT constant-fold beyond literals, to avoid cross-implementation divergence), it MUST be the only rule under that (on, state variable); coexistence with any other rule → Error;logic: AND conditions elements (a single condition is itself a top-level conjunct); expr form = the direct children of a top-level and node; conditions nested under or/not must never be used as proof basis (treated as unprovable); precision rule (MUST): in Simple form with logic: OR, the whole condition group is treated as a single top-level conjunct (OR sub-conditions are not conjuncts) — since it is not an eq conjunct, the pairwise proof always falls into (3) as unprovable — unless the set values are identical (idempotent, exempt);eq conjunct on the same state.<v> with different constants; (b) both rules have a top-level eq/in conjunct on the same field with disjoint constant sets (eq treated as a single-element set);enabled: false rules are excluded from the check (also inert at runtime), but when they conflict with enabled rules lint SHOULD warn — otherwise enabling one is instantly a violation.transitions count cap (§6a.4), the pairwise check is O(n²) bounded.audit_as consistency (decidable, MUST): all enabled transition rules under the same on event name MUST have the same audit_as (one event type = one audit posture); a mismatch is a load-time Error. Rationale: the successful-transition record is event-granular (§6a.5) — one event = one record, one audit_as slot; if the rules within one event disagree on audit_as, the record has no unique value and two implementations would answer differently, forking the audit chain. enabled: false rules are excluded from the check, but lint SHOULD warn when they disagree with enabled rules’ audit_as.set value MUST belong to that variable’s values; the transition direction (e.g. authorized → revoked) is determined by the values enum plus the set declaration — the engine executes only declared transitions and does not infer undeclared ones (fail-closed).rules[].when, rules[].unless, transitions[].when, decision-table cells, transitions[].set keys) referencing state.<name> whose <name> is not declared in state;"state" (no path segment) — a bare state has no state-variable name and cannot be resolved, reject;transitions[].when referencing free context (§6a.7);rules[].when / rules[].unless / decision-table cells referencing event.* — the event namespace exists only in the transition evaluation context (§6a.7); rule evaluation (§7.0.2) has no current event; such a reference is a load-time Error (otherwise it null-propagates to always-false under E11, silently dead rule — exactly the fail-silent that E11 exists to prevent).transitions set, never writable directly by external parties (context / caller).state.<name> namespace — strictly isolated from context fields (context is externally writable; state is engine-held).state does it enter the controlled state namespace (e.g. state.authorization). Paths whose first segment is not state — context.state.*, tool.state.*, etc. — still resolve as context; but lint SHOULD warn, to avoid reviewer misreading.state is a reserved namespace: context top-level fields MUST NOT use state as a key, to avoid collision with the state snapshot; a field path exactly "state" (no path segment) is a load-time error (§6a.2).state.<name> reuses the existing field node (the state. prefix routes to controlled state reads in resolveField).initial), so there is no “missing” semantics; referencing an undeclared state variable is a load-time error (§6a.2).state.*’s evaluation semantics are fully identical to context fields (all of §7.3 applies) — gt/between/length over a state value behave the same as over a context field: length(state.x) for enum strings uses code-point length, between over a non-numeric is always false, string comparison follows E10 code-point order. The only difference: a declared variable has no missing case (E11 never fires).as_of/temporal_state (E1).The state space MUST be finite; exceeding the caps rejects loading (Error):
| Dimension | Cap | Basis |
|---|---|---|
State variable count (state array length) |
≤ 4 | authority / freshness / approval / task — four common state kinds |
Single-variable values enum size |
2–4 | revocation 2 states (valid/revoked); authority/approval/task at most 3–4 states |
| State combination space (∏ values length) | ≤ 4⁴ = 256 | anti combinatorial explosion |
Transition rule count (transitions array length) / document |
≤ 32 | bounds the pairwise mutual-exclusion check (§6a.2) to O(n²) |
Distinct on event-name count |
≤ 16 | finite event-name space, anti unbounded growth |
Event payload |
≤ 8 keys / depth ≤ 2 / single value ≤ 256B | controlled payload, anti large payload (§6a.7) |
Delegation chain depth (the hop count of per-entity multi-instance) is an organization-layer constraint, not a single-instance FSM state-space dimension, and is outside this section’s resource caps (§6a layering boundary).
State outside the kernel ≠ state un-auditable. Audit closes over three rings plus a cryptographic link ring, all required:
| Ring | Audits | Mechanism |
|---|---|---|
| ① Transition chain | every step of authorized→revoked | transition event = transition audit record (audit_as mapped to the §6a.2 narrowed set), previous_hash serial anchoring |
| ② Snapshot | the state value read at evaluation | state_snapshot enters the DO, in the hash preimage (§7.0.3) |
| ③ Validity | only transition rules may change state, in a legal direction | engine validates the transition (fail-closed); undeclared transitions do not execute |
state_snapshot structurestate_snapshot structure (provenance anchoring, MUST): state_snapshot is:
{ values: { <state-variable-name>: <value> }, # the state variables actually read in this evaluation (on-demand, not the full state)
state_version: <uint>, # starts at 0 (i.e. genesis); +1 per successfully committed event transaction
transitions_head: <hash> } # hash of the most recent state-changing audit record; genesis hash initially
All three enter the DO hash preimage (§7.0.3). state_version and transitions_head anchor the snapshot to the transition chain:
sets within one event merge into a single increment; starts at 0 (i.e. genesis).transition_error record (on EvaluationError, §6a.2) is linked into the chain via previous_hash (keeping the chain complete), but does not apply set, does not increment state_version, and does not move transitions_head.doc_tree_hash definition and compare it with the doc_tree_hash in the on-chain genesis record; a mismatch means the chain does not belong to this document (reject, preventing cross-document chain transplant); Step 1 (traversal) — traverse the full chain from genesis along previous_hash, applying each success record’s set and incrementing the counter; when the counter == state_version, for every state variable appearing in state_snapshot.values, the replayed full state’s value for that variable MUST equal values[variable], and the current record hash MUST equal transitions_head; error records are traversed only — not applied, not counted.state_snapshot serialization normalizationvalues keys are ordered by state-variable-name RFC 8785 JCS key order (UTF-16 code-unit ascending), serialized as a JSON object;temporal_state, state_snapshot, canonical_trees) is listed in §8.2a.Two implementations differing in any of key order / encoding / field order would compute different DO hashes, violating the “semantics = tree = hash” core promise — hence all three MUST be normalized.
Event injection authentication (MUST): event injection MUST be engine-authenticated — the actor identity (§6a.7) enters the transition audit record; an unauthenticated event MUST be rejected (fail-closed). No arbitrary caller may inject revoke/authorize events.
Event authentication evidence (SHOULD): an authenticated event SHOULD carry verifiable authentication evidence — a signature or proof digest (e.g. a JWS kid + digest), written into the transition audit record; when the actor identity enters the chain, its authentication evidence SHOULD enter the chain too, so “who approved it” is independently verifiable (closing the hole that actor as a mere string is forgeable). Note: the current chain records only the actor string — no evidence field exists in the §8.2a.2 record field order — so “who approved” is not yet independently verifiable.
audit_as is not proof of human approval:
audit_asis only an audit label and carries no approval proof; an attacker can inject a forged event withactor: human-1. The only auditable form of human approval = the authenticated identity layer injecting the event as a human identity (actorenters the chain) —audit_as: REQUEST_HUMANdoes not mean “this transition is itself a human approval”.
Successful transition record (first-class on-chain record, MUST): a transition that successfully commits set produces a successful transition record, with the format:
{ type: "transition", event_id, on, actor, at, audit_as, set, state_version, previous_hash }
type is fixed to "transition";audit_as = the unified audit-carrier value of the on event (§6a.2 narrowed set + same-event consistency check), entering the hash preimage;set = the merged mapping of all rules’ set in this transaction { <state-variable-name>: <value> }, keys ordered by state-variable-name RFC 8785 JCS key order (UTF-16 code-unit ascending) (same-variable same-value idempotent merge is guaranteed unambiguous by the conflict check);state_version = the version number after this transaction commits (= previous version + 1);previous_hash = the hash of the previous record on the chain (genesis or an earlier transition/transition_error);transition_error record (first-class on-chain record, MUST): a transition_error (on EvaluationError, §6a.2) is a first-class on-chain record with the format:
{ type: "transition_error", event_id, on, actor, at, audit_as, error, errored: true, previous_hash }
audit_as = the unified audit-carrier value of the on event (§6a.2 narrowed set + same-event consistency check);error = the error description of the first (in transitions definition order) EvaluationError rule (§6a.2 event-handling atomicity: stop at first);previous_hash (keeping the chain complete);set, does not move transitions_head, does not increment state_version (consistent with replay verification);set/state_version field, errored is always true; the full field order and fixed key set are in §8.2a.Guard warnings not recorded (MUST): a transition guard’s type_mismatch warning only affects the evaluation fold (§7.3(a),
errored=false) and is not recorded — neither success nor error transition records carry a warnings field; the guard’s audit focus is the transition outcome (whethersetcommits), not evaluation-process warnings.
Rejected-event disposition (MUST, on-chain / off-chain separation): unauthenticated, duplicate-event_id, and over-resource-cap events MUST NOT enter the hash chain; they are recorded in off-chain audit storage (actor if known, event name, event_id, rejection reason, content sanitized before persisting) — on-chain holds only trusted records, off-chain holds intrusion traces. This ensures: the auditor sees the full trusted state-transition history on-chain, and sees injection attempts / replays / over-limit attacks off-chain, without cross-contamination.
Genesis record (MUST): at document load the engine MUST generate a genesis audit record for initial (initial snapshot + document canonical-tree hash). This is the starting point of transition chain ① — a document with initial=authorized can answer on-chain “why it was authorized to begin with”.
Two load entries (MUST): ① create (first instantiation of an instance) — generates genesis (carrying instance_id, see below), writing a new chain; ② restore (recovering from an existing chain) — MUST verify the latest authoritative head (§6a.9) and fail-closed on failure (reject the load, MUST NOT reset state to initial). Implementations MUST distinguish the two, MUST NOT treat restore as create — otherwise a restart resets state to initial, and if initial is an authorized state the revocation is bypassed.
Genesis preimage byte-level definition (MUST):
doc_tree_hash = sha256(JCS({ name, state, transitions })) — the state-machine document-level canonical form (distinct from §8.2’s expression-tree-level canonical). doc_tree_hash anchors the state-machine identity (state + transitions) and does not include rules — rule-set provenance is carried by the DO-layer rule_set_version.id (RFC-002 §2.3); the two each cover their own ground: changing state/transitions changes doc_tree_hash (prevents cross-document chain transplant), changing rules changes rule_set_version.id (prevents rule-set drift), with no overlap.rule_set_hash binding (MUST): doc_tree_hash does not include rules, so state machines with the same name and structure but different rules can share the same chain — the meaning of a state value (e.g. revoked) depends on the rules consuming it. Therefore the DO-layer rule_set_hash (§8.2a.1a, the full rule-set semantics hash) and doc_tree_hash jointly anchor “state machine + rules”: recomputation/verification MUST have both consistent, otherwise the chain/decision is judged not to belong to the current (state-machine, rules) combination. metadata.name is not a security boundary — the same name does not mean the same rules; identity binding is by hash.
{ type: "genesis", instance_id, protocol, doc_tree_hash, initial: {variable names RFC 8785 JCS key order (UTF-16 code-unit ascending)}, at, previous_hash: null }, where instance_id is the instance identity (distinct instances of the same document MUST use distinct instance_id, so their genesis hashes differ and a chain cannot be transplanted across instances; optionally carries the relationship binding: grantor, grantee, scope summary).doc_tree_hash preimage field order and fixed key set (MUST, pinned field by field):
name → metadata.name (document name, identity anchor)
state → [ { name, values, initial } ] (state-space declarations; excludes display_name)
transitions → [ { on, name, audit_as, reason, enabled, when, set } ] (transition rules; excludes gloss)
null and the key is not omitted (fixed key set); strings NFC (E10), numbers JCS (§8.2 encoding).transitions[].enabled encodes true (§6a.2 default true, not null); a missing state[].initial encodes null (§6a.1 initial is a MUST field, so a missing one is an invalid document rejected at load; the absent encoding is defined here only to forestall implementer divergence).transitions[].when stores the compiled S-expression (§8.2 tree-level canonical), not the source YAML — so Simple and Expression spellings of the same semantics hash identically (E7); absent (unconditional transition) encodes null.state[].display_name, transitions[].gloss, and all metadata fields except name (description/category/decision/tags).Here initial’s keys are ordered by state-variable-name RFC 8785 JCS key order (UTF-16 code-unit ascending); the previous_hash: null key is not omitted (fixed key set). The three kinds’ full field order and fixed key set are in §8.2a.
initial(genesis record) andstate_snapshot.values(§7.0.3) are both “state-variable → value” mappings, but belong to two different preimages — the genesis record and the DO evaluation result — with their own fixed field names:initialexpresses the initial state,valuesexpresses the current state read at evaluation; implementers MUST serialize under each respective field name and MUST NOT conflate them.
Normative guidance (SHOULD): documents carrying authorization semantics SHOULD set initial to the most conservative sentinel (unestablished/revoked); an explicit bootstrap authorization must go through a transition event with an actor, leaving on-chain provenance, rather than a groundless initial=authorized.
Concurrency semantics (per document instance MUST serialize): event handling and evaluate() on the same document instance MUST be mutually exclusive; the state_snapshot that evaluate() sees is a consistent snapshot at evaluation start, no intermediate state allowed.
Ring ③ is what the snapshot model (state materialized externally) cannot do, and is the core of an explicit state machine: the engine verifies “how the state legally arrived here” instead of accepting external state wholesale.
within/rate are an extremely narrow special case of this FSM (state=counter, transition=+1, output=over-limit). State blocks generalize them without changing their existing semantics. The two coexist: within/rate remain injected via temporal_state; state blocks are injected via state.*. State blocks and within/rate counters do not share a namespace: state blocks use state.<name> (driven by the §6a.1 state declaration), within/rate use field+operator+value (§5.2 count-isolation key) — the two key-isolation rules are independent and do not collide.
Transition-rule guard evaluation is likewise brought into the “controlled injection” model (E1) and does not read free context. The event object, guard context, and compilation discipline MUST be as follows:
Event object, structure MUST be:
event:
event_id: "evt-2026-09-12-0001" # unique within the document; replay-deduplication basis
on: revoke # event name (matches transitions.on)
at: "2026-09-12T10:00:00Z" # engine-injected UTC moment (the transition evaluation's as_of)
actor: "agent-A" # authenticated event-source identifier
payload: { ... } # restricted payload (see below)
| Field | Type | Description |
|---|---|---|
event_id |
string | Unique within the document; replay-deduplication basis (the same event_id is processed only once) |
on |
string | Event name, matching transitions.on |
at |
string | Engine-injected UTC moment (the transition evaluation’s as_of); MUST NOT be supplied externally (E9 no-wall-clock applies equally) |
actor |
string | Authenticated event-source identifier (provided by the identity layer, not self-asserted by payload); string MUST be NFC-normalized (E10, enters the transition-audit-record hash preimage) |
payload |
object | Restricted payload: ≤8 keys, depth ≤2, scalar leaf values, single value ≤256B; keys MUST NOT contain . and MUST NOT be the four reserved field names (event_id/on/actor/at) |
event.* resolution mechanism (MUST):
event.* reuses the field node’s first-segment interception just like state.* (in resolveField, a first segment of event routes to controlled event reads);event.event_id / event.on / event.actor / event.at + payload keys (bound to the event.<key> namespace); a payload key’s value may be an object (depth ≤2), and guards may read a nested event.<key>.<sub> path (depth ≤2), resolved like a context field path (§3);exists/not_exists can sense absence);Transition evaluation context (the input to when): the when guard’s evaluation context MUST be only the following two kinds, and MUST NOT read free context:
state.* (the state before any of this event’s transitions take effect, §6a.2);event.* (event.event_id/event.on/event.actor/event.at + payload keys event.<key>; event.at is the engine-injected UTC moment, i.e. the transition evaluation’s as_of).A guard reading any field outside state.* and event.* (free context) MUST be rejected at load (Error, see §6a.2 load-time validation full set).
Compilation and evaluation discipline: transition evaluation MUST go through the same single compilation pipeline as rule evaluation (E7); its warning / errored conventions are fully consistent with §7.3.
Time and freshness (no time trigger, MUST): the state machine has no time trigger — transitions are event-driven only, state never expires on its own. When a “freshness” state (e.g. fresh/stale) is needed, choose one of two modes:
on: expire event, driving the fresh → stale transition (the engine keeps no timer);when compares expiry moments using event.at / epoch_ms, deciding expiry at event arrival (no background timer).Example (authorization expiry, mode 2):
state:
- name: authorization
values: [authorized, revoked]
initial: revoked
transitions:
- on: exercise
audit_as: ESCALATE
reason: auth_expired
when:
gt:
- epoch_ms: { field: "event.at" }
- epoch_ms: { field: "event.expires" }
set: { authorization: revoked }
The engine does not “auto-expire over time” — time exists only as a controlled event attribute (
event.at) or as a guard time-comparison input, consistent with E9 (no wall clock, controlledas_ofinjection).Expiry/entitlement carrying (MUST): scalar constraints such as expiry and quota MUST be carried by an authorization-root-signed credential field (written into the chain, committed with the authorize event), MUST NOT be carried by the caller-payload
event.expires— a caller can omit or extendexpires, making expiry meaningless. When a guard referencesevent.expires, its value MUST come from the authenticated credential (§6a.5.4 event authentication), not free payload. “Inject anexerciseevent before everyevaluate” is a boundary obligation (integration requirement, §6a.8): the engine does not advance time on its own, and expiry is triggered by the boundary injecting an exercise event before each evaluation.
§6a’s authority state is consumed by an enforcement boundary (the Action Guard / tool-call guard, §9.1) that gates security-sensitive side effects. evaluate() is a pure function (E1): it returns a decision plus a state_snapshot = { values, state_version, transitions_head } (§7.0.3), but it does not itself commit the gated side effect — that commit happens in the enforcement boundary, a distinct component, after evaluate() returns and releases the instance lock.
This leaves a check/act window: evaluate() may return ALLOW against state_version = N, a revoke event may then commit state_version = N+1 before the effect lands, and the effect would still execute under a now-superseded authorization lineage.
Enforcement-boundary re-validation (MUST): for a security-sensitive side effect whose authorization depends on §6a state, the enforcement boundary MUST ensure that no state-change event affecting the authorization lineage commits between the state version used for the authorization decision and the commit of the protected effect. A conforming boundary satisfies this by one of:
{ state_version, transitions_head } under the instance lock (§6a.5) and compare it with the decision’s state_snapshot; on any mismatch, fail closed (do not execute — treat as unavailable/stale authority, AV-05/AV-10/AV-14 semantics); orLayering (engine vs. boundary): the engine MUST expose the re-validation primitive — the current state_version/transitions_head readable under the instance lock — but it does not execute the side effect and does not hold the lock across the effect commit on the boundary’s behalf (E1: evaluation is pure; the commit is outside the engine). Check/act atomicity is therefore an integration obligation the enforcement boundary discharges by re-validating against the engine’s snapshot anchor, not an engine-side side-effect-execution guarantee.
Adversarial conformance vector (V-STATE): authorized@N → evaluate(ALLOW@N) → revoke@N+1 (before effect commit) → attempt the effect. Expected: the effect MUST NOT execute under the stale ALLOW; the boundary re-validates and fails closed (or otherwise closes the boundary). This is the stateful continuation of AV-05 / AV-10 at the execution boundary.
§6a.5’s replay verification proves that a snapshot is consistent with a valid transition-chain prefix (integrity/provenance), but it does not prove that the prefix is the current latest authoritative prefix (freshness). The two MUST be distinguished.
Rollback attack (adversarial scenario): authorized@N / HN → revoke commits @N+1 / HN+1 → a restart/recovery/replica restoration presents a valid historical prefix ending at {N, HN} → replay verification of that prefix succeeds (it was not forged or modified, only superseded) → evaluation sees authorization = authorized → the enforcement boundary re-validates against the same restored instance and again observes {N, HN}. Result: a previously revoked authorization becomes exercisable again without violating the existing hash-chain replay checks.
Latest-authoritative-head freshness (MUST): for a security-sensitive side effect whose authorization depends on §6a state, the enforcement/recovery boundary MUST ensure that the accepted { state_version, transitions_head } is the latest authoritative state for the same document instance — not a historical prefix superseded by a later authoritative state. A conforming boundary satisfies this by one of:
If the freshness of the latest authoritative state cannot be established, the authority exercise MUST fail closed (treat as unavailable/stale authority, AV-05/AV-10/AV-14 semantics).
Layering (engine vs. boundary): hash-chain integrity is guaranteed by the engine (§6a.5); latest-authoritative-head freshness across restart/recovery/replica boundaries is an integration obligation the enforcement/recovery boundary discharges via an external anchor — the engine maintains no cross-instance persistent epoch and does not decide recovery policy on the boundary’s behalf (E1: evaluation is pure). The durable freshness anchor is provided by the organization/deployment layer; and the fail-closed property MUST be preserved: if the enforcement boundary cannot establish that the restored { state_version, transitions_head } is sufficiently fresh relative to the authoritative persistence state, authority-bearing effects MUST NOT proceed — successful replay/integrity verification is never sufficient evidence that authority is still current.
Adversarial conformance vector (V-STATE): authorized@N / HN → revoke commits @N+1 / HN+1 → restore a valid historical prefix (ending at @N / HN) → replay verification succeeds → evaluate a protected action → attempt the effect. Expected: the protected effect MUST NOT execute using the superseded authority state; the system MUST establish that {N, HN} is still the latest authoritative state, discover that it has been superseded, or fail closed when freshness cannot be established.
§6a.2 models authorize/revoke uniformly as event-triggered state transitions (the FSM’s F function), with event injection authenticated by actor (§6a.5.4). But actor authentication only proves “who triggered the event”, it does not prove “whether the triggerer is entitled to establish that authority”. Without constraining the provenance of establishing/re-establishing authority, revocation degenerates into a reversible local state bit — a revoked subject can flip revoked → authorized by merely triggering an authorize/re-authorize transition without proving where the renewed authority came from.
Authorization-root provenance (MUST): for a state variable carrying authorization semantics, any transition that makes the authority exercisable (i.e. a set that moves the variable into an “exercisable” value such as authorized) MUST carry authorization-root provenance — the triggering event’s actor MUST be attributable to a principal/authority entitled to establish/re-establish that authority (the authorization root), not the authorized subject itself. After revocation, a revoked → authorized re-authorization MUST be supported by a new valid authorization basis; a local state transition alone (without authorization-root provenance) MUST NOT make the authority exercisable again. A descendant or previously-authorized subject MUST NOT restore its own revoked authority by merely triggering an authorize/re-authorize state transition.
Layering (engine vs. boundary): the engine exposes an identifiable marker for “authority-establishing/re-establishing” transitions (the reason semantic marker, e.g. reason: authorize) and records actor into the transition audit chain (§6a.5.4, already present); determining authorization-root eligibility (who is entitled to establish that authority) is an integration obligation of the enforcement boundary / organization layer — the §6a single-instance FSM does not model the P→A→B authority chain (§6a.1 layering boundary); who is entitled to authorize is adjudicated by the organization layer. Before committing an authorize/re-authorize event the enforcement boundary MUST verify the actor’s authorization-root eligibility; when the authorization root cannot be established, it MUST fail closed (treated as unavailable/unauthorized, AV-05 / AV-10 / AV-14 semantics).
Interface to organization-layer invariants: this section’s authorization-root provenance is the single-instance-FSM-layer primitive support for the organization layer’s delegated-authority invariants (authority non-amplification INV-01, narrow-only constraint inheritance INV-03, transitive revocation INV-04) — the organization layer consumes the “establishing/re-establishing authority must be attributable to the authorization root” primitive to guarantee the chain-level INV invariants. Their full definitions live in §6b, beyond this section’s single-instance FSM scope (§6a.1 layering boundary).
§6a.8–§6a.10 state integration obligations as MUST; their conformance depends on the enforcement/recovery boundary being able to read the state anchors the engine exposes. To remove the “integration obligations without interface definitions” dangling, this specification pins the minimal revalidation API the engine MUST expose (interface signatures, language-neutral; the host-language type mapping is implementation-defined, but field semantics MUST match the table below):
| Primitive | Signature | Semantics |
|---|---|---|
get_head |
() → { state_version: uint, transitions_head: hash } |
returns the current document instance’s latest authoritative head (the comparison anchor for §6a.9 freshness and §6a.8 revalidation); read under the instance lock |
get_value |
(name: string) → string \| undefined |
returns a state variable’s current value (the underlying primitive of §6a.3 state.<name> read-only resolution) |
get_chain |
() → AuditRecord[] |
returns the transition-chain audit records (genesis / transition / transition_error, §6a.5 three kinds) |
snapshot |
(read_vars: string[]) → { values, state_version, transitions_head } |
on-demand state snapshot (§6a.5.1 / §7.0.3, entering the DO hash preimage) |
inject_event |
(event: Event) → { record: AuditRecord \| null, disposition: committed \| noop \| rejected, error? } |
injects an event (§6a.2.1 atomic processing; receipt committed / noop / rejected) |
get_head is the interface form of the §6a.8 revalidation primitive: before committing a protected effect, the enforcement boundary MUST compare get_head() against the decision’s state_snapshot (§7.0.3) and fail closed on mismatch (§6a.8 method 1);inject_event’s receipt is the interface form of §6a.2.1 event-processing atomicity: rejected (over-limit / duplicate / authentication failure / guard error) and noop (no match / guard unsatisfied) MUST be distinguished — the former is recorded off-chain, the latter is silent (§6a.2.1 / §6a.5.4);Attribution: The delegated-authority security invariants (INV-01–INV-05) and associated adversarial conformance vectors (AV-01–AV-14) were proposed by Ravindra Annam and subsequently refined and developed through technical review and collaboration with OpenOBA.
§6a defines the single-instance FSM (the state machine of a single authorization relationship); this section defines the security invariants of the delegation chain (multiple authorization relationships composed along “authorization root → intermediate node → authorized subject”) — constraining “how authority propagates along the delegation chain”, the normative semantics of the organization behavior layer. Layering: §6a provides “verifiable adjudication of authorization state”, this section guarantees “the delegation chain’s security invariants”; per-relationship multi-instance state is carried by the organization layer instantiating one document per relationship (§6a.1 layering boundary). In this section “delegation” means delegation of authority (propagating authority along the authorization chain), distinct from the §6 DELEGATE decision type (human-in-the-loop: handing “what the machine cannot handle” to a human or process).
All delegation, assignment, re-delegation, transitive delegation, privilege brokering, downstream constraint change, and revocation MUST NOT let effective authority exceed or escape the originating authority chain:
effective_authority(subject) ⊆ authority(chain)— effective authority is a subset of the originating authority chain; no operation may amplify it.
Each invariant = property + violation shape + normative assertion.
effective_authority ⊆ authority(chain). A delegator grants authority ⊆ its own; authority cannot be amplified through the chain.effective_authority(delegate) MUST ⊆ authority(chain); aggregate consumption of multiple child grants against one bounded originating authority MUST satisfy aggregate conservation.constraints(delegate) MUST ⊆ constraints(delegator); downstream constraint changes MUST NOT widen.authority(resource) MUST ⊆ authority(tool) ⊆ authority(skill) ⊆ authority(agent).This section generalizes §6a.9 (latest-authoritative-head freshness) to the delegation-chain layer: §6a.9 constrains single-instance-FSM state-head freshness, this section constrains the freshness of a delegation-chain ancestor’s revocation state.
Before exercising authority that depends on a revocable ancestor, the enforcement boundary MUST establish that revocation state satisfies the configured freshness requirement; absence of visible revocation MUST NOT by itself establish continued validity; when freshness cannot be established, fail closed. Mechanism-neutral: monotonic epoch / lease / version vector / signed status object / online introspection / equivalent mechanisms.
Convergence criterion = decision + matched_invariant + first_invalid_boundary. Full vector table in the independent conformance suite (vectors/ + conformance/CONFORMANCE.md).
Evaluation = the pure-function process (E1) by which the expression tree (the compiled product of rules) judges the input context node by node. This section defines the evaluation input contract, the algorithm steps, and the output contract, to align implementers and users.
The evaluation input is a context object carrying the current state of the rule’s subject entities, namespaced by Entity (§3):
context:
tool: # Entity: tool
name: "issue_refund"
args: { amount: 8000, order_id: "O1024" }
country: "CN" # free-form fields directly at the context top level (aligned with the RFC-002 `context` field)
role: "operator"
# other Entities: agent / task / workflow / human / guardian (provided as needed)
tool.name, context.amount, tool.args.amount) resolve by key path on the context object (§3);as_of (the evaluation moment, UTC), temporal_state (the within/rate sliding-window state) and state.* (the §6a authority-state snapshot) are injected by the engine and are controlled external inputs (E1);A field contract declares a context field’s type and semantics, used for: ① LLM prompt generation (constrain field names, no inventing); ② gloss rendering (display_name, G3); ③ runtime type checking under strict mode.
| Field | Type | Description |
|---|---|---|
field |
string | field name (snake_case, enters the kernel/hash) |
display_name |
object | bilingual display name { zh, en } (enters gloss, G3; render-layer only, not entering kernel/hash) |
type |
string | field type: number / boolean / string / string[] / date |
optional |
boolean (optional, default true) | whether the field may be absent; false means required, and a missing required field is an evaluation error (fail-closed, E12) |
description |
string | field semantics |
default_value |
any (optional) | default value when the field is absent |
definition_period |
string (optional) | definition period: DAY / MONTH / YEAR / ETERNITY (aligned with OpenFisca Variable.definition_period); informational field — used only for parameter-evolution hints, not entering the kernel/hash, not participating in evaluation |
provenance |
string (optional, informative) | fact-source type (§7.0.1b: system_observed / llm_asserted / user_input / config / derived); informational field — declares the fact source for governance and rule review, not entering the kernel/hash, not participating in evaluation (normative red line in §7.0.1b) |
Under strict mode, a contracted field whose value type does not match type records a type_mismatch warning (same as §7.3(a) strict mode); a contracted field with a default_value and absent is evaluated by the default (not E11 null propagation); a contracted field with optional: false and absent and no default_value is an evaluation error (errored=true, E12 fail-closed).
Corresponding to §6’s systematic treatment of the conclusion space via the 13 decision types, the premise space of the context object is classified by source as follows. This subsection is an informative classification, deliberately asymmetric with the output-side closed enumeration: decision types enter the fold and the hash and must stay closed and frozen (the existing §6 closed-enumeration requirement); fact sources evolve openly (new integration forms keep appearing), do not enter evaluation semantics or the hash, and may be freely extended:
| Source type | Description | Typical example |
|---|---|---|
system_observed |
system-observed: values directly observed by the host system | context.amount taken from a payment-system response |
llm_asserted |
LLM-generated assertion: tool names, arguments, intent descriptions output by the model — possibly hallucinated; an “assertion to be verified”, not yet a “fact” | tool.name, tool.args.* |
user_input |
direct input by a human user | an amount submitted by the user in a form/dialogue |
config |
configuration snapshot: static parameters fixed at deployment | thresholds, allowlists |
derived |
derived value: computed from the above sources | cumulative amount, aggregate statistics |
ERDL’s fact-neutrality stance (design note): the engine does not vouch for the truth of facts — for an llm_asserted field, ERDL guarantees “deterministic evaluation + complete preimage (recomputable) for the given input”, not “the input itself is trustworthy”. Fact-trustworthiness governance is borne by three layers: ① the field contract (§7.0.1a) declares the source via the informational provenance field; ② rule authors explicitly consume source differences (rules over llm_asserted fields written more strictly than over system_observed — expressed by rules, not by implicit engine down-weighting); ③ the controlled injection of state.*/event.* and event authentication (§6a.3/§6a.5.4) provide source assurance for the authority lineage. Implementations MUST NOT admit provenance into evaluation semantics or hash preimages — mixing trust assessment into deterministic evaluation would break the closed-kernel positioning of “semantics = tree = hash” (E1/E6).
Input: rule set rules[] + context object context (state-machine events already processed first, see step 0)
Output: the decision result (see 7.0.3)
0. Events happen first (happens-before declaration, no extra action): events are already processed at arrival (eager, §6a.2).
evaluate() acquires the instance lock (§6a.5); its state.* input is the state after all arrived events have committed.
1. Sort: by priority ascending (smaller = higher priority)
2. Group: execute rings in order 0 to 3 (0 kernel → 1 recovery → 2 approval → 3 advice)
3. Within each ring, evaluate each rule in order:
a. the unless exemption is judged before when — on exemption, record and skip the rule (unless and when share the same evaluation context: context + state.* read-only injection)
b. the compiled when expression tree judges context node-by-node (true / false / error)
c. a match does not short-circuit (only `EMERGENCY_HALT` is the exception, noted below): only `EMERGENCY_HALT` short-circuits on match; other decisions (including DENY/ROLLBACK/QUARANTINE/WORKFLOW) continue (an override ALLOW may cover)
d. override: only the DENY → ALLOW direction, never to a less-safe state (§7.1)
4. Fallback: no rule matched → metadata.decision (fallback decision, §2.2)
5. Summarize: produce decision + matched_rules + evidence (canonical_tree / hash / eval_trace / state_snapshot)
total_evaluated).when is one of: ① when: "true"; ② its compiled product is a literal true node (including the compiled product of a decision table with only a default row). The determination is made at compile time, independent of the writing form; implementations MUST NOT constant-fold beyond literals (1 eq 1 is not folded into catch-all, avoiding cross-implementation divergence). Note: rules[].when is a MUST field (§4.1), so there is no “when omitted” rule form; transitions[].when may be omitted (§6a.2), and its unconditional transition is determined separately by the conflict check (0) of that section.when expression tree (and/or/quantifier) evaluate fully (all children evaluated, left→right, no short-circuit) — short-circuiting would change eval_warnings/state_snapshot.values/record counts and thus the DO hash; implementations MUST evaluate fully, MUST NOT short-circuit.EMERGENCY_HALT short-circuits on match; WORKFLOW enters the workflow state machine on match (§6 decision type WORKFLOW; note: the authority state machine is in §6a, they are distinct), but WORKFLOW MUST NOT block later interception — it starts only when no DENY/ROLLBACK/QUARANTINE/EMERGENCY_HALT matched; DENY/ROLLBACK/QUARANTINE do not short-circuit — evaluation continues to judge whether an override ALLOW covers them.The evaluation result MUST contain the following fields:
Absence encoding (MUST, §8.2a): nullable object-type fields (
primary_instruction,primary_reason,primary_explanation,primary_correction,temporal_state,state_snapshot) encode asnullwhen valueless, key MUST NOT be omitted; list-type fields (unless_exemptions,eval_warnings,canonical_trees) encode their empty state as[](key not omitted), andmatched_rulesis always an array — keeping the DO hash-preimage structure constant. Exception:erroredis a boolean flag (E3), always valued (EvaluationError → true, otherwise false); it does not participate in absence encoding. The full empty-state encoding boundary is in §8.2a.
| Field | Description |
|---|---|
decision |
the final decision (one of the §6 enum, or the fallback decision) |
matched_rules |
the matched rules (in evaluation order) |
unless_exemptions |
rules exempted via unless (recorded separately, not counted in matched_rules) |
primary_instruction |
the primary instruction (ALLOW + instruction scenario) |
primary_reason |
the primary reason (DENY and other blocking scenarios) |
primary_explanation |
the primary explanation (may be bilingual) |
primary_correction |
the correction text (CORRECT decision; sourced from the rule field correction, see §4.1) |
total_evaluated |
the total number of rules whose unless/when evaluation was actually entered (rules skipped by catch-all inertness are NOT counted) |
total_matched |
the total number of rules matched |
temporal_state |
the within/rate sliding-window state snapshot (encoded as null when nothing matched, key not omitted, §8.2a) |
state_snapshot |
the state.* values read during evaluation: { values, state_version, transitions_head } (encoded as null when no state was read, key not omitted, §8.2a); enters the DO hash preimage (§6a.5) |
canonical_trees |
the matched rules’ canonical tree snapshots (tree = canonical-tree JSON) and hashes (sha256: prefix), E6 evidence |
rule_set_hash |
the hash of the full rule-set semantics (including the fallback decision; §8.2a.1a), closing the gap that canonical_trees covers only matched rules’ when trees, so a third party can verify “no other rule should have matched” |
eval_warnings |
non-fatal warnings collected during evaluation (E3) |
indeterminate_rules |
the list of rule names whose when evaluated to unknown (S3 top-level unknown folding; empty state encodes [], key not omitted, §8.2a) |
errored |
whether an evaluation error occurred (E3); Guard contexts fail-close, covering all tiers (E12) |
as_of |
the evaluation moment injected by the engine (ISO UTC, E9) |
The evaluation evidence (canonical_tree snapshot, result hash, eval_trace) are independently recomputable derived products (§8.2, E6) — canonical_tree enters the hash, eval_trace does not (§8.3).
priority ascending (smaller value = higher precedence);override level (critical > high > normal > low);override enumeration: critical > high > normal > low (default normal);override is allowed only in the DENY → ALLOW direction (it MUST NOT override to a less-safe state); an override ALLOW (critical/high) covers a restrictive rule r only if level(o) > level(r) and ring(o) ≤ ring(r) — an outer ring (higher ring number) MUST NOT cover an inner ring (lower ring number); the tightening direction (DENY / ROLLBACK / QUARANTINE covering ALLOW) is the default consequence of “MUST NOT override to a less-safe state” — it does not compare ring and needs no override (an override on a tightening decision is inert and does not affect tightening) — an override may only move freely toward a more-safe state (tightening), while moving toward a less-safe state (relaxing) requires explicit override authorization;when is the literal true (matches unconditionally), whether its then is DENY or ALLOW and whether or not it carries override, MUST NOT override the decision established by any explicit-condition (non-literal-true when) rule. A fallback rule takes effect only when no explicit-condition rule matches (synonymous with the decision-table “default row” in §5.4). Rationale: a fallback rule carries the weak, general intent of “all other cases”, while an explicit-condition rule carries the strong, specific intent of “this particular case”; letting the fallback rewrite an explicit decision is an “override to a less-safe state” and violates the safety monotonicity of item 5.When multiple rules match, the final decision is determined by a “decision-strength partial order + fold” (not by first-match or an undefined order). The strength order of the 13 decision types (smaller number = stronger; the final decision takes the strongest):
| Strength | Decision type | Category |
|---|---|---|
| 0 | EMERGENCY_HALT | Terminal (short-circuit on hit) |
| 1 | DENY / ROLLBACK / QUARANTINE | Restrictive (tightening) |
| 2 | REQUEST_HUMAN / WORKFLOW | Human-in-the-loop |
| 3 | ESCALATE | Human-in-the-loop |
| 4 | DELEGATE | Human-in-the-loop |
| 5 | DEFER | Human-in-the-loop |
| 6 | CORRECT | Guidance |
| 7 | GUIDE | Guidance |
| 8 | ALLOW | Allow |
NOTIFY does not participate in the main decision: NOTIFY is a “side action” (record, not block). When matched it is recorded in
matched_rulesand the notification list, and it does not change the finaldecisionor participate in the fold.
Fold rules (set-based, permutation-invariant, MUST):
Decision merge is set-based (collect all hits first, then decide once), not a sequential fold — a sequential fold makes “can an override ALLOW cover a DENY” depend on evaluation order (whoever has smaller priority/ring is evaluated first), breaking permutation invariance. Let:
override ∈ {critical, high};level(o) > level(r) and ring(o) ≤ ring(r) (an outer ring MUST NOT cover an inner ring; level is the override level critical > high > normal > low);locked — it MUST NOT be covered by any override ALLOW (locked rules always count as “uncovered restrictive” and do not participate in the covering test). Rationale: tier 0–2 is the safety baseline, and the override authorization of tier ≥3 business-panorama rules MUST NOT relax safety-baseline interception; locked is derived from the rule’s tier (tier ≤2 and restrictive ⇒ locked), not an independently writable field; a rule whose tier is absent (undeclared) is never locked.Fold pseudocode (MUST):
resolve(hits, indeterminate_rules, on_indeterminate):
if any hit is EMERGENCY_HALT: return it # EMERGENCY_HALT is terminal (strength 0, short-circuits on hit)
R = hits where restrictive(decision)
O = hits where decision == ALLOW and override in {critical, high}
locked(r) = tier(r) <= 2 # tier 0-2 interception lock (MUST NOT be covered)
uncovered = R where locked(r) or not exists(o in O: level(o) > level(r) and ring(o) <= ring(r))
if uncovered non-empty: return strongest(uncovered) # restrictive all strength 1, take smallest ring
nonRestrictive = hits where not restrictive(decision) and decision != NOTIFY
if indeterminate_rules non-empty and context == Guard: nonRestrictive += on_indeterminate # S3: unknown → synthetic hit (default REQUEST_HUMAN)
if nonRestrictive non-empty: return strongest(nonRestrictive) # by strength partial order
return undefined # falls through to fallback
restrictive(d) = d in {DENY, ROLLBACK, QUARANTINE}
level(rule) = override level (critical > high > normal > low; absent = normal)
strongest(S) = smallest strength order; tie → earliest definition order
The strength order guarantees “safety monotonicity”: the tightening (restrictive) direction is free, the relaxing (allow/guidance) direction requires
overrideauthorization, and the same direction converges by intervention strength — any two implementations thereby produce the same final decision without relying on first-match order.
| # | Constraint |
|---|---|
| E1 | Evaluation is a pure function: no side effects, no implicit external state, no clock reads; the counting of within/rate, the authority state snapshot (state.*, §6a) and as_of are controlled external inputs — the evaluation phase reads only the pre-state, except that within/rate counting is a controlled side effect: the within/rate record is written during evaluation (when the counter is below threshold, i.e. the allowed operation), so evaluate() is not idempotent for these two operators — a repeated evaluation of the same input counts again; the state body is held by the engine, the expression tree reads only snapshots |
| E2 | Fixed-point decimal scale=14 + half-even string serialization (evaluation scope: output precision, not canonical encoding); intermediate computation uses high-precision bounded rationals, rounding only at output nodes |
| E3 | Evaluation errors are recorded as eval_warnings with errored=true; folding direction follows E12 by tier |
| E4 | Resource limits (graded): Grade A arithmetic depth≤2 / tree depth≤6 / nodes≤64 / array≤10000 / no nested quantifiers / regex input length≤10000; Grade B tree depth≤10 / nodes≤256 / arithmetic depth≤4, quantifier nesting≤2; Grade C not applicable |
| E5 | Type checking at load; when and expr MUST NOT coexist |
| E6 | Tree as evidence: canonical_tree (a tree snapshot) serves as evaluation evidence and enters the hash; eval_trace is a recomputable derived product, not entering the hash |
| E7 | Simple and Expression compile to the same evaluation core; a second evaluator is forbidden |
| E8 | Quantifier safe folding: empty array → all/any/none all unknown (not false; not(unknown)=unknown does not fail open) |
| E9 | No wall-clock reads; as_of is injected by the engine and recorded in the audit record |
| E10 | String NFC normalization |
| E11 | undefined sentinel semantics (null propagation, see §7.3) |
| E12 | Evaluation error handling: Guard contexts (safety-boundary evaluation; the reference evaluate() is a Guard context) default to fail-close — all tiers fold to the blocking side (DENY); non-Guard contexts (simulation/analysis) fail-close tier≤2 and fold tier 3–5 to false |
The kernel explicitly excludes: string concatenation, regex replacement, bitwise operations, date formatting, recursive references, and user-defined nodes — to keep evaluation closed and verifiable.
Operational suggestion (non-evaluation semantics, non-MUST): a wall-clock per-rule time cap (e.g. ≤50ms) is a deployment-layer DoS-guard operational suggestion — it does not enter the evaluation result, does not enter the hash, and is not part of the deterministic semantics; the reference implementation substitutes deterministic node/depth limits (E4’s graded structural limits) for wall-clock timing, consistent with E1 (pure function) and E9 (no wall-clock reads).
The following semantics MUST be explicitly annotated in the document and vectors, to avoid semantic misunderstanding against standard implementations:
Agent contexts are highly dynamic and missing fields are the norm. Evaluation uses Kleene three-valued logic (true / false / unknown), avoiding the fail-open of “missing/type-mismatch folds to false + not flips it”:
| Scenario | Behavior |
|---|---|
| equality/numeric comparison on a missing field (undefined/null) | returns unknown (records a type_mismatch warning; not an NPE) |
== null / != null check |
returns true / false normally (senses field presence) |
| type-mismatched comparison | returns unknown (records a type_mismatch warning; no implicit conversion; errored=false) |
| arithmetic on a missing field | propagates unknown (a missing/null operand makes the whole arithmetic unknown, errored=false); only division by zero, overflow, and illegal types (string/object into arithmetic) are EvaluationError (errored=true) |
unknown operand of a logic node (and/or/not) |
Kleene: not(unknown)=unknown; and any false→false, all true→true, otherwise unknown; or any true→true, all false→false, otherwise unknown |
Top-level unknown folding (MUST): a rule whose when evaluates to unknown does not match (a rule matches only when when === true) — unknown neither allows nor blocks, and does not trigger then; a Guard context configures a top-level fallback via metadata.on_indeterminate (default REQUEST_HUMAN; tier 0–2 MAY configure DENY) and records it in the audit.
not and unknown (fail-open guard, MUST):
not(unknown) = unknown— a missing/type-mismatched operand remains unknown after not, never flipping to true (closing the “missing field → comparison false → not true → fail-open” hole).
Warning asymmetry (cross-implementation MUST reproduce exactly): type-mismatch/missing comparisons uniformly record a
type_mismatchwarning (errored=false);in(non-array right), string nodes (contains/match/starts_with/ends_with),length(non-str/array),aggregate(non-array/non-numeric elements), and quantifiers (all/any/noneover a non-array) also record atype_mismatchwarning. Third-party implementations MUST reproduce this warning set exactly.
Strict mode (evaluation option, default off): in lenient mode a comparison-node type mismatch silently yields unknown (as above). With strict mode on, a comparison node (
eq/ne/gt/gte/lt/lte/between) type mismatch records atype_mismatchwarning (erroredstill false) — making LLM parameter type errors (e.g. a number written as the string"100" gt 50) visible in the audit. Strict mode only changes the “comparison-node type mismatch” warning behavior, not the three-valued folding semantics; lenient mode MUST stay as-is (backward compatible).
under standard quantifier semantics all(empty)=true (vacuous truth). This specification deliberately deviates: all/any/none(empty) all fold to unknown (not false) — preventing both “nothing to check yet judged as allowed” (vacuous truth) and not(all(empty)) flipping to true (fail-open), and record the safe fold in the audit record. An over that is not an array (missing/scalar/object) is a type_mismatch warning: all/any/none fold to unknown with errored: false. Third-party implementations MUST adopt this folding semantics.
intermediate computation uses high-precision bounded rationals (e.g. 128-bit integer numerator/denominator); only output nodes round to scale=14 + half-even string serialization (IEEE 754-2019 ROUND_HALF_EVEN). Conformance compares the scale-14 fixed-point value (numerically equal), not the string spelling: trailing zeros are insignificant ("35" ≡ "35.0"). This “string serialization” is the evaluation scope (output precision) and does not enter the canonical_tree hash; the canonical encoding scope is §8.2 (a typed decimal-string object, not JCS number).
errored=true, E12 fail-closed), not a silent fold to false.eq/ne/gt/gte/lt/lte/between) acts on the exact rational (the unrounded intermediate value), not the rounded scale-14 string.round node is round(x, digits, mode), digits is the rounding precision (0–14, default 0), mode is half_even (IEEE 754-2019 ROUND_HALF_EVEN, default) — “round half up” MUST NOT appear, half-even is uniform.the match node MUST satisfy: ① input length cap (≤10000 chars); ② linear-time matching — prefer a deterministic engine (RE2-class, no backtracking), or be constrained by the safe syntax subset; ③ regex syntax subset (EBNF below). The safe syntax subset MUST be restricted to regular languages: backreferences (\1–\9, \k<name>) and lookaround ((?=) / (?!) lookahead, (?<=) / (?<!) lookbehind) are forbidden — such non-regular constructs depend on backtracking order, cannot be made byte-deterministic, and cannot be expressed by the SMT verifier (erdl-formal). Inline case flags ((?i)) are not provided (matching is always case-sensitive, §5.2). A regex that violates these limits (nested quantifiers, backreferences, lookaround, or an input-length violation) folds to unknown with a regex_re_dos warning and errored: false — it is not an E3 EvaluationError; folding to unknown (not false) keeps not(match(...)) from flipping (avoiding fail-open, §7.3(a)). MUST NOT use “regex steps” as the cap metric — a RE2-class engine has no backtracking step count and cannot reproduce it cross-implementation; substitute an input-length cap + linear-time engine (deterministic, portable).
Safe syntax subset EBNF (MUST):
regex := alternation
alternation := concatenation ( "|" concatenation )*
concatenation := repetition+
repetition := atom quantifier?
quantifier := "?" | "*" | "+" | "{" n ( "," m )? "}"
atom := literal | "." | char_class | group | anchor
group := "(" ( "?:" | "?<name>" )? alternation ")"
char_class := "[" "^"? char_item+ "]"
anchor := "^" | "$"
literal := non-meta character | backslash-escaped character
Forbidden (non-regular constructs, MUST NOT): backreferences (\1–\9, \k<name>), lookaround ((?=) / (?!) lookahead, (?<=) / (?<!) lookbehind), inline flags ((?i) etc.).
| Function | Empty-array result | Basis |
|---|---|---|
count(empty) |
0 |
standard counting semantics |
sum(empty) |
0 |
empty-sum identity |
avg(empty) |
unknown |
safe fold (avoid division by zero; unknown keeps not(avg(empty)) from flipping) |
min(empty) |
unknown |
safe fold (standard +Infinity, disabled; unknown does not fail open) |
max(empty) |
unknown |
safe fold (standard −Infinity, disabled; unknown does not fail open) |
The over of aggregate MUST be an array; a non-array (missing/scalar/object) returns null + type_mismatch warning (folded to false). count(missing) and count(empty array) differ: the former is type_mismatch, the latter is 0.
all time nodes evaluate uniformly in UTC, guaranteeing byte-for-byte consistency across implementations and time zones:
YYYY-MM-DD) parses as UTC; date-time parses per ISO 8601 with timezone (literal whole-second precision, fractional-second literals not supported), and without a timezone suffix as UTC; the epoch_ms node returns a millisecond timestamp (Unix epoch milliseconds) — “whole-second” constrains only date-time string-literal parsing, not the internal millisecond timestamp;date_part): always takes UTC components;date_add, month_last_day): UTC calendar arithmetic; the date_add amount MUST be an integer (a non-integer returns null + a type_mismatch warning, folding to false) — a duration is an integer unit, half-even rounding of “add 1.5 months” has no business meaning, so implicit rounding is forbidden;days_between): UTC millisecond difference ÷ 86400000, floor (rounded toward negative infinity — e.g. -0.5 → -1, not toward zero);YYYY-MM-DDThh:mm:ss.sssZ, millisecond precision) — the engine-injected as_of and event at serialize to this shape; implementations MUST hold a UTC millisecond timestamp internally and serialize to this fixed form (language-neutral, not bound to a specific host API name).Business local time zone is converted by the engine to a UTC instant when injecting as_of; the evaluator computes as a UTC pure function.
an E4 structural resource-limit violation (nodes / tree-depth / arithmetic-depth / array / quantifier-nesting over the grade limit) throws — the engine returns value: null with value_type: "null" and threw: true (not an E3 EvaluationError; errored stays false). A regex ReDoS violation (§7.3(d)) folds to false + regex_re_dos (not a throw). An E5 load-time exclusivity violation records value: true (= violation detected). The E12 fold and errored rules above apply to evaluation vectors only.
when Minimum-Completeness Constraintswhen: "true" means “applies to all operations” and is allowed only for advisory rules:
| Rule | Level |
|---|---|
when: "true" MUST NOT pair with then: DENY |
MUST NOT |
when: "true" MUST NOT pair with then: EMERGENCY_HALT |
MUST NOT |
when: "true" MUST NOT pair with then: CORRECT |
MUST NOT |
when: "true" MUST NOT pair with then: REQUEST_HUMAN |
MUST NOT |
when: "true" MAY pair with then: ALLOW + instruction |
MAY |
when: "true" MAY pair with then: NOTIFY |
MAY |
| Safety rules (category=security) MUST contain at least 1 condition | MUST |
Tool-interception rules SHOULD contain a tool.name condition |
SHOULD |
File-operation rules SHOULD contain tool.args.path |
SHOULD |
Command-operation rules SHOULD contain tool.args.command |
SHOULD |
An ERDL document is carried in YAML and can be losslessly converted to JSON. The canonical tree (canonical_tree) is serialized as a JSON object.
The expression tree is the single benchmark object for evaluation, hashing, and recomputation. For its hash to be byte-for-byte identical across implementations, the tree MUST have a unique canonical form:
| Canonicalization rule | Description |
|---|---|
| Fixed node order | child nodes are arranged in canonical order (strict left→right), independent of source writing order |
| Field names load-bearing | field reference paths are load-bearing — frozen once published ([FREEZE-1]); aliases MUST be normalized first |
| Literal canonicalization | the canonical encoding scope of number literals is a typed decimal-string object {"n":"0.15"} (a number 0.15 encodes as {"n":"0.15"}, while the string "0.15" stays the bare value "0.15" — no collision), not JCS IEEE 754 number serialization — avoiding the loss/collision of integers beyond 2^53 and decimals beyond ~17 significant digits under IEEE754; strings NFC-normalized |
| var canonicalization | only $ / $.path, with path segments as definite byte sequences |
| Metadata stripping | comments, source line numbers, formatting, authors, and other non-semantic metadata never enter the canonical tree |
The object of tree hashing is the canonical tree, not any particular implementation’s memory representation or serialized text. Two structurally equivalent trees (differing only in field writing order, whitespace, or variable naming) produce exactly the same byte sequence and hash after canonicalization.
Versioned domain-separation prefix for tree hashing (MUST): tree hash =
sha256("erdl-tree-v3:" + canonical bytes)—erdl-tree-v3:is a versioned domain-separation prefix (an S2 breaking change), preventing old/new encoding-scope tree hashes from ever being conflated (a tree with no number literal would hash identically across the change but the prefix differs).
The canonical tree is serialized as an S-expression (JSON shape); each node is a single-key object (MUST have exactly one key). Node shapes (MUST, pinned node-by-node):
| Node | S-expression shape | Note |
|---|---|---|
| literal | bare value (string / boolean / null); a number literal encodes as the typed object { n: "<decimal string>" } |
numbers typed ({n} key is a reserved key, not a node name — eliminates number/string collision), strings NFC (E10); an array literal (e.g. the right operand of in) is a bare array |
| field | { field: "path" } |
field reference (snake_case, FREEZE-1) |
| var | { var: "path" } |
path is only $ or $.path (root or dot path) |
| and / or | { and: [children…] } / { or: [children…] } |
logic (n-ary, see child ordering below) |
| not | { not: child } |
single child |
| eq / ne / gt / gte / lt / lte | { eq: [left, right] } |
comparison (binary) |
| in | { in: [left, right] } |
right operand is an array |
| contains / match / starts_with / ends_with | { contains: [left, right] } |
string (binary) |
| exists | { exists: child } |
presence |
| length | { length: child } |
length |
| between | { between: [value, min, max] } |
ternary |
| all / any / none | { all: { binding, over, predicate } } |
quantifier |
| add / sub / mul / div / round | { add: [children…] } |
arithmetic (n-ary) |
| days_between | { days_between: [from, to] } |
time difference |
| epoch_ms | { epoch_ms: child } |
timestamp |
| date_add | { date_add: { unit, base, amount } } |
date arithmetic |
| date_part | { date_part: { unit, arg } } |
component extraction |
| month_last_day | { month_last_day: child } |
end of month |
| count / sum / avg / min / max | { count: child } |
aggregation |
| fn | { fn: { name, args } } |
function delegation |
| casefold / trim / path_normalize | { casefold: child } |
string normalization (unary, extension profile) |
| in_set | { in_set: { ref, digest, value } } |
external-list membership (extension profile) |
Child ordering (MUST): the child arrays of every node keep definition order (left→right) and are not sorted — sorting would change semantics (e.g. and/or evaluation order relates to the §7.3(a) warning asymmetry), so it is forbidden. The keys inside a node object (e.g. date_add’s unit/base/amount, the quantifier’s binding/over/predicate, fn’s name/args) are sorted by JCS key order (§8.2 encoding scope).
Precedence clause (MUST): when the prose and a vector conflict, the prose prevails (vectors are consistency tests of the reference implementation, not the normative authority); if a vector exposes semantics the prose does not cover, the conclusion MUST be written back into the prose (§10.3.3 “implementable from the spec alone”).
The DO hash preimage of an evaluation result — its field order, key set, and absence encoding — MUST be defined as follows, otherwise two implementations will necessarily compute different hashes (v2.2 added a structured field to the DO, state_snapshot, whose local key ordering is defined but whose overall preimage is not — a global anchoring gap):
Fixed field order (MUST):
context → decision → matched_rules → unless_exemptions → primary_instruction → primary_reason
→ primary_explanation → primary_correction → total_evaluated → total_matched
→ temporal_state → state_snapshot → canonical_trees → rule_set_hash → eval_profile → eval_warnings → indeterminate_rules → errored → as_of
Versioned domain-separation prefix of the DO hash (MUST): the language-layer evaluation-result DO hash =
sha256("erdl-eval-do-v3:" + JCS(preimage) bytes)—erdl-eval-do-v3:is a versioned domain-separation prefix, the same mechanism as the tree-hash prefixerdl-tree-v3:(§8.2): hashes before and after a DO-preimage schema change (e.g. the S1/S4 closure) cannot be conflated, nor can the DO hash and the tree hash.
contextenters the DO (MUST):contextis the evaluation input’s context object (§7.0.1, calledcontextin RFC-002), entering the DO hash preimage — so that “this decision, made against this input” is independently recomputable, rather than only the output side (“decision → matched rules → tree”).contextsits first in the field order, semantically forming the “input → decision” closed loop; a DO missing the input context cannot answer “what input was this decision made against”.Adding the
contextfield is a breaking change (MUST be explicitly labeled): addingcontextchanges the DO hash preimage’s field order and key set (invalidating all old-schema DO hashes), a v2.3 breaking change — verifiers MUST select the recomputation schema viaeval_profile.spec_version(§8.2a.1b). The specific position ofcontextin the field order (first/middle/last) does not affect semantics, as long as the complete input context enters the preimage; this specification does not treat “position” as a semantic constraint. Note the layering: this section’s “evaluation-result DO” is the language-layer hash preimage of the evaluation result (contextfirst); the RFC-002 governance-layerdecision-object(v1.5) is a different hash preimage where the context object’s field name iscontext(one of the CORE 14, not first) — the two are two names for the same context object at different layers, but belong to two distinct DO hash preimages and MUST NOT be conflated: a language-layer breaking change does not alter the RFC-002decision-objectfield order, and vice versa.
Verification approach (language-layer DO): the language-layer evaluation-result DO’s cross-implementation determinism is assured by (a) the formal pinned field order / key set / absence encoding of §8.2a.1 (normative formal definition, not a prose approximation) and (b) engine self-verification (
evaluation-object.spec.ts). It does not require a dedicated cross-implementation vector family — the RFC-002 governance-layerdecision-object(v1.5) is the cross-implementation vector layer; the language layer is pinned formally and self-verified.
Merkle commitment of context (optional profile, MUST support):
contextmay contain personal information and secrets, and entering it fully into the DO conflicts verifiability with compliant deletion. Optionally commitcontextvia a Merkle commitment — each field value is a salted-hash leaf, the DO stores only the Merkle root (context_hash), and recomputation discloses fields on demand with proofs. Whether or not committed, desensitization MUST precede evaluation (the value used for evaluation equals the recorded value; MUST NOT record the raw text first and then evaluate).
Fixed key set (MUST): a valueless key is encoded as null, keys MUST NOT be omitted (keeping the preimage structure constant); arrays in occurrence order; strings NFC (E10); numbers typed {"n":"<decimal>"} (§8.2 encoding scope). Empty-state encoding (MUST): list-type fields (matched_rules, unless_exemptions, eval_warnings, canonical_trees) encode their empty state as [] (key not omitted); only nullable object-type fields (primary_instruction/primary_reason/primary_explanation/primary_correction, temporal_state, state_snapshot) encode as null when valueless — arrays are always arrays, objects may be null, a unique boundary.
DO sub-structure key sets and key order (MUST, pinned per field): sub-structure entries also have fixed key sets and key orders (S1) — absence encodes null, keys not omitted; list-type empty state []:
| Sub-structure | Entry key order (fixed) | Notes |
|---|---|---|
matched_rules[] / unless_exemptions[] |
rule_id → rule_name → decision → priority → ring → instruction → reason → correction → explanation → alternative → corrected_args |
canonical entries of matched/exempted rules; optional fields encode null when absent; explanation/alternative encode as {zh, en} (NFC) when bilingual; corrected_args is the parameter-correction object of a CORRECT decision (snake_case keys), null when absent |
canonical_trees[] |
rule_id → tree → hash |
E6 tree evidence: tree is the canonical-tree S-expression (§8.2.1); hash is the tree hash (sha256: prefix, domain-separation prefix in §8.2) |
eval_warnings[] |
code → node_type |
code is a closed enumeration (E3): type_mismatch / division_by_zero / quantifier_empty / aggregate_empty / regex_re_dos / array_over_limit / invalid_date / not_ruleable; the free-text message does not enter the preimage (diagnostic wording is not cross-implementation constant and would break byte-identity); node_type encodes null when absent |
temporal_state[] |
rule_id → operator → field → window_ms → count → limit |
within/rate window-count snapshot (the output side of the S6 counting-key elements); operator ∈ {within, rate}; limit is carried only by rate entries (the N of "N/1m"), within entries have no limit key |
indeterminate_rules[] |
(string array) | the list of rule names whose when evaluated to unknown (S3), empty state [] |
rule_set_hash is the hash of the full rule-set semantics, closing the gap that canonical_trees covers only matched rules’ when trees — unmatched rules, then/priority/ring/override/enabled/unless all enter rule_set_hash, so a third party can verify “no other rule should have matched”.
Rule canonical object (MUST): each rule is canonicalized to:
{ name, when_tree, unless_tree, then, priority, override, ring, tier, enabled, instruction, reason, correction, explanation, alternative }
when_tree = the rule when compiled to its S-expression (§8.2 tree-level canonical); an unconditional (catch-all) rule encodes as the literal true node;unless_tree = the rule unless compiled to its S-expression; absent unless encodes as null;then = the decision type (§6 enumeration);priority = number (JCS number);override = critical/high/normal/low (default normal);ring = 0–3 (default 3);tier = 0–5 (absent encodes as null; tier 0–2 is the safety baseline, §7.1a locked);enabled = boolean (default true);instruction / reason / correction / explanation / alternative = the rule text fields (M5: they surface as primary_* after evaluation, and correction is safety-relevant — the correction text changes the interception semantics); absence encodes null; bilingual objects encode as {zh, en} (NFC).rule_set_hash computation (MUST):
rule_set_hash = sha256(JCS({ fallback_decision, on_indeterminate, rules: [rule canonical objects…] }))
fallback_decision = metadata.decision (default ALLOW);on_indeterminate = metadata.on_indeterminate (default REQUEST_HUMAN, S3);rules sorted by the unified sort key (ring ascending → priority ascending → override level → definition order) — consistent with the ring-grouped execution order of §7.0.2 (ring-major);eval_profile records the evaluation options — strict mode, context, field contracts, spec version. These options change the decision or eval_warnings, yet were absent from the DO, so a third party could not recompute. eval_profile enters the DO preimage:
{ strict, context, contract_hash, spec_version }
strict = strict-mode switch (§7.3(a), default false);context = evaluation context (guard = security-boundary evaluation (evaluate()), analysis = simulation/analysis); disambiguation: this context is an evaluation option (Guard vs non-Guard, the E12 folding direction), a different concept from the context object context of §7.0.1 (first in the DO preimage field order) — the shared key name is historical; the two are distinguished by their enclosing structure (eval_profile.context vs the top-level preimage context);contract_hash = sha256 hash of the field contracts (hashed contract reference; null when no contracts);spec_version = spec version (e.g. v2.3);engine_id does not enter the preimage (S4): engine_id is an implementation identifier (reference implementation: erdl-engine) — entering the preimage would break cross-implementation byte-identity; it is carried only by the DO transport envelope (diagnostic use) and does not affect the hash; the preimage’s schema selection is borne by spec_version.Transition-chain audit-record preimage field order (MUST, three kinds): the transition chain is composed of three kinds of audit records — successful transition (type: "transition"), transition error (type: "transition_error"), and genesis (type: "genesis"). The three have different field sets; their respective field order and fixed key set MUST be as follows (the type field enters the preimage to distinguish kinds):
Successful transition record (transition):
type → event_id → on → actor → at → audit_as → fired → reason → set → state_version → previous_hash
Transition error record (transition_error):
type → event_id → on → actor → at → audit_as → fired → reason → error → errored → previous_hash
Genesis record (genesis):
type → instance_id → protocol → doc_tree_hash → initial → at → previous_hash
Fixed key set and absence encoding (MUST): each kind’s key set is the field order listed above (missing fields are not padded across kinds); the keys of set/initial are ordered by state-variable-name RFC 8785 JCS key order (UTF-16 code-unit ascending); strings NFC (E10); numbers JCS (§8.2 encoding scope); previous_hash absent (genesis only) is encoded as null and the key is not omitted. fired = the transition rule names fired by this transaction (name, defaulting to on), multiple joined by , into a string (M7); reason = the transition’s semantic identifier (the reason field of §6a.2), taking the transaction’s first non-empty value, encoding null when absent (M7).
This “hash the tree, verify gloss equals the tree” mechanism lets gloss obtain cryptographic anchoring of the tree without needing byte-for-byte identity or entering the hash.
The integration goal of ERDL is to extract critical decisions from model inference, framework code, or informal conventions, and turn them into loadable, executable, and verifiable rule assets. ERDL is typically delivered as YAML/JSON rule documents, makes decisions through when → then, and outputs hashable evaluation evidence that can be recomputed byte for byte. The following three integration paths correspond to three typical engineering integration points.
Role: In an AI Agent pipeline, ERDL is the deterministic gate between LLM intent and system execution — the Agent may generate actions, but whether an action is permitted must be determined by rule evaluation.
Simulation: A customer-service Agent receives a user request for a refund of 8,000 yuan. The LLM converts the intent into the tool call issue_refund(amount=8000, order_id=O1024). Before the call actually reaches the payment system, Action Guard packages the tool name, arguments, and session context into a context object and submits it to ERDL for evaluation. In the rule set, R1 is written as when tool.name == "issue_refund" and tool.args.amount > 5000 → REQUEST_HUMAN, while R2 is written as when tool.name == "issue_refund" → ALLOW. Because R1 matches first, the system returns REQUEST_HUMAN. The Agent stops calling the payment tool, generates a human approval task instead, and returns a “manual review required” message to the user.
Integration points: First, rules are evaluated independently of the model, so prompts no longer carry the safety boundary. Second, match records, input digests, canonical_tree, and result hashes are written together to audit logs, making every interception replayable. Third, rule changes require only updating the ERDL document, without rewriting the Agent framework, tool implementations, or model prompts.
Role: In the MCP ecosystem, ERDL rules are exposed as standard tools through an ERDL MCP Server. Any MCP-compatible Agent can invoke the same rule set, enabling compliance distribution and mutual recognition across implementations.
Simulation: An enterprise deploys its fund-compliance rules as an ERDL MCP Server and exposes the guard_check tool. A third-party Agent prepares to execute a high-value refund but does not possess the enterprise rules. Before execution, it calls guard_check through MCP, passing an action description (action=issue_refund, amount=8000, channel=payment). The ERDL MCP Server loads the rule set, performs evaluation, and returns decision=REQUEST_HUMAN, matched_rule=R1, hash=0x9f.... Based on the returned value, the third-party Agent stops direct execution, enters a human approval workflow, and records the decision evidence in its own execution log.
Integration points: The key here is “Rules-as-a-Service.” Rules are no longer hard-coded in a specific Agent framework; they are distributed through a standard protocol. Clients from different vendors, programming languages, and runtime environments can consume the same decision logic. Because the response includes rule-match and hash information, callers can archive decision evidence for later auditing or independent recomputation even without storing the original rule text. When rules are upgraded, only the MCP Server side needs to be updated; clients do not need to refactor their execution chains.
Role: Inside a rule engine, ERDL is the rule definition language itself, responsible for expressing business policies declaratively and providing deterministic evaluation semantics.
Simulation: An anti-money-laundering engine needs to express “large transaction involving a high-risk country → block.” The business team writes the ERDL rule when context.amount > 10000 and context.country in [high-risk country list] → DENY. Here the high-risk country list (>256 items; FATF/OFAC-style sanction lists often reach thousands) is referenced externally via the in_set{ref,digest} node — the list’s versioned hash enters the rule while the list body is injected by the engine context, not subject to in’s 256 inline cap (§5.2.2). After loading the YAML, the engine does not treat it as ordinary configuration; it compiles it into an expression tree: comparison nodes handle amount thresholds, set nodes determine country membership, and logical nodes perform the AND operation. The engine then executes according to the E1–E12 evaluation semantics and outputs DENY. The rule can also generate canonical_tree and a hash value; another implementation can recompute the same result simply by loading the same rule and the same input.
Integration points: The core value here is “verifiable determinism.” ERDL not only makes rules readable, but also allows engine implementations to be constrained by test vectors. V-ENGINE-class test vectors can prove that different engines, platforms, and versions produce byte-for-byte identical results for the same rule. As a result, a rule engine is no longer merely an internal black box inside a business system; it becomes an executor that can be independently verified by third parties. Regulators, auditors, or platform operators can determine whether the engine computed correctly based on inputs, rule hashes, and evaluation evidence.
A minimal ERDL document plus one evaluation, walking the full “write → load → evaluate → get result” chain (pipeline in §2.4).
Step 1 · Write the rule (refund.erdl.yaml):
protocol: "erdl/v2"
version: "2.2.0"
metadata:
name: "refund-guard"
description: "Refund amount control"
category: coding
decision: ALLOW
rules:
- name: "SEC-001-refund-limit"
description: "Refunds over 5000 require human approval"
priority: 10
when:
logic: AND
conditions:
- field: "tool.name"
operator: eq
value: "issue_refund"
- field: "tool.args.amount"
operator: gt
value: 5000
gloss: "tool.name equals issue_refund and tool.args.amount is greater than 5000" # engine-generated (G2)
then: REQUEST_HUMAN
message: "Refund amount over 5000, human approval required"
Step 2 · Load + validate + compile: parse the YAML, validate it, then compile when into an expression tree (§2.4 steps ①②③).
Step 3 · Evaluate: given the context object:
context:
tool:
name: "issue_refund"
args: { amount: 8000 }
Rule SEC-001 matches (tool.name == "issue_refund" and amount > 5000).
Step 4 · Result:
decision: REQUEST_HUMAN
matched_rules: ["SEC-001-refund-limit"]
primary_reason: "Refund amount over 5000, human approval required"
total_evaluated: 1
total_matched: 1
If the input is changed to amount: 100, the rule does not match, and the metadata.decision fallback applies → decision: ALLOW.
See §4.2 (Simple rule), §5.3 (Expression rule), and §5.4 (Decision Table).
State-block complete example (§6a):
protocol: "erdl/v2"
version: "2.2.0"
metadata:
name: "delegated-refund-authority"
description: "Revocation and freshness of delegated refund authority"
category: security
decision: DENY
state:
- name: authorization
values: [authorized, revoked]
initial: revoked
display_name: { zh: 授权状态, en: authorization state }
transitions:
- on: bootstrap
audit_as: DELEGATE
reason: authorize
when: { eq: [{ field: "state.authorization" }, "revoked"] }
gloss: "authorization state equals revoked" # engine-generated (G2), lint-enforced
set: { authorization: authorized }
- on: revoke
audit_as: DELEGATE
reason: revoke
gloss: "true" # unconditional transition: literal true
set: { authorization: revoked }
rules:
- name: "SEC-001-revoked-denies-exercise"
description: "Block protected operations when authorization is revoked"
priority: 10
ring: 0
when:
logic: AND
conditions:
- field: "state.authorization"
operator: eq
value: revoked
then: DENY
message: "authorization revoked"
Note: the
stateblock is already updated by events (transitions) before rules evaluation;state.*is controlled injection (§6a.3), not a context field.
state_snapshot)Evaluation output example (with state_snapshot, a V-STATE prototype): after the above example undergoes two events — bootstrap (authorize) and revoke (revoke) — the rule evaluation outputs:
# Event sequence (processed before rule evaluation, eager):
# bootstrap → set authorization: authorized (state_version 0 → 1)
# revoke → set authorization: revoked (state_version 1 → 2)
decision: DENY # matches SEC-001, authorization=revoked
matched_rules: ["SEC-001-revoked-denies-exercise"]
primary_reason: "authorization revoked"
total_evaluated: 1
total_matched: 1
temporal_state: null # no within/rate match
state_snapshot: # §6a.5, enters the DO hash preimage
values: { authorization: revoked } # state read at evaluation (on-demand, not full)
state_version: 2 # 2 committed transactions (bootstrap + revoke)
transitions_head: "sha256:…" # hash of the revoke record
canonical_trees: [ { rule_id: "SEC-001-…", tree: …, hash: "sha256:…" } ]
eval_warnings: [] # list-type field empty state is []
errored: false
as_of: "2026-09-12T10:00:00Z"
The semantics of this specification MUST be proven by independently recomputable test vectors. The expression-layer vectors (V-ENGINE / V-GLOSS / V-PROJ) cover: 34 nodes × 4 scenarios (normal/boundary/exception/empty), E1–E12 semantics, the Simple 30-operator compile mapping, and gloss rendering templates; the state-layer vectors (V-STATE) cover all MUST semantics of §6a: event-object validation (event_id/on/actor/at/payload restricted load), same-variable conflict check (0)–(4) positive/negative cases and same-event audit_as consistency, single-event multi-rule atomicity (stop at the first EvaluationError, commit all at once on full pass), guard-error fail-closed with transition_error chain position (no set applied / no version increment / no head movement), state_version/transitions_head replay verification, duplicate event_id idempotent drop, unmatched-event silence, load failure for rules referencing event.* / undeclared state.*, catch-all vs explicit-rule two-pass interaction, and enforcement-boundary check/act re-validation (authorized@N → ALLOW@N → revoke@N+1 before effect commit, fail-closed, §6a.8), latest-authoritative-head freshness (authorized@N/HN → revoke@N+1/HN+1 → restore historical prefix → replay succeeds → reject effect, anti-rollback, §6a.9). The resolution-layer vectors (V-RESOLVE, R01–R13) cover the §7.1 ring/override/catch-all resolution semantics (including the tightening-direction boundary R08/R13).
Five-step verification: load vector input → generate expression tree → recompute evaluation result → compare with the answer → judge consistency.
Third-party Runner verification flow (from zero to conformance):
An implementation MUST prove conformance via independently recomputable test vectors (§10); the sole criterion for “conforms to the specification” is that, for the same (rule set, context, evaluation options, state) input, it produces a DO hash byte-for-byte identical to the vector answer. (Here “DO” denotes the RFC-002 governance-layer decision object — the cross-implementation vector layer; the language-layer evaluation-result DO of §8.2a.1 is pinned formally and self-verified by the engine, and does not carry a separate vector family.) Conformance has two levels:
casefold/trim/path_normalize), external lists (in_set), state blocks (§6a), and function delegation (Appendix D) are optional extensions — an implementation MAY not support them, but once declared it MUST fully conform to the corresponding section, and MUST declare its supported extension set in its implementation metadata.match node MUST have an input-length cap + linear-time engine (§7.3(d)), forbidding backreferences / lookaround;in list ≤256 inline cap (large lists use in_set external reference);hasOwnProperty rather than in, MUST NOT access the object prototype chain;state.* is controlled-injection (§6a.3), updatable only by transitions[].set, not writable externally;previous_hash (§6a.5); the DO hash preimage has fixed field order + JCS (RFC 8785) + SHA-256;context fully enters the DO hash preimage (§8.2a.1), in tension with “verifiability” and “compliant deletion”. Implementations MUST support the context Merkle commitment (optional profile) — field values are salted-hashed into leaves, the DO stores only the Merkle root, with per-field disclosure on recomputation;actor identity enters the chain, authentication evidence (JWS kid + digest) SHOULD enter the chain (§6a.5.4), without the raw identity credential (currently only the actor string is recorded);This specification’s extension points (new node types, new decision types, new writing forms, new profiles) MUST follow the registration mechanism:
casefold/in_set extension profiles), existing node semantics MUST NOT change, nodes MUST NOT be removed;protocol/version identifiers; breaking changes are limited to major.| Group | Nodes | Count |
|---|---|---|
| Value | field · var · literal | 3 |
| Logic | and · or · not | 3 |
| Comparison | eq · ne · gt · gte · lt · lte | 6 |
| Set | in | 1 |
| String | contains · match · starts_with · ends_with | 4 |
| Existence/measure | exists · length · between | 3 |
| Quantifier | all · any · none | 3 |
| Arithmetic | add · sub · mul · div · round | 5 |
| Time | days_between · epoch_ms · date_add · date_part · month_last_day | 5 |
| Aggregate | aggregate (count/sum/avg/min/max) | 1 |
Total 34 nodes.
| Family | Operators | Count |
|---|---|---|
| Comparison | eq · ne · gt · gte · lt · lte | 6 |
| List | in · not_in | 2 |
| String | contains · not_contains · match · starts_with · ends_with | 5 |
| Boundary negation | not_starts_with · not_ends_with | 2 |
| Existence | exists · not_exists | 2 |
| Length | length_gt · length_gte · length_lt · length_lte · length_eq | 5 |
| Range | between · not_between | 2 |
| Count | count_gt · count_gte · count_lt · count_lte | 4 |
| Modifier | within · rate | 2 |
Total 30 operators (28 condition operators + 2 condition modifiers).
See §6.
For scenarios explicitly excluded by the kernel but genuinely needed, function delegation (FnRegistry) is provided as a controlled fallback:
| Constraint | Description |
|---|---|
| Registration | a function MUST be registered before it can be referenced; unregistered calls are rejected |
| Sandboxed execution | restricted environment under resource quota and timeout |
| Determinism exemption declaration | functions on the Guard evaluation path MUST declare and guarantee determinism |
| Auditability | every call is recorded in the audit record for offline verification |
fn node reading state (§6a): an fn node is not part of the 34-node frozen kernel (Appendix D controlled fallback, Grade C) — the fn’s arguments may reference
state.*/event.*(controlled state is injected via a field node and passed in as an argument); but the fn itself MUST remain deterministic (per the determinism-exemption declaration above), and MUST NOT write state (state is updatable only bytransitions[].set, §6a.3).
Rule grading (Grade):
| Grade | Expression form | Audit SLA |
|---|---|---|
| A | pure Simple (30 operators) | highest, plain-text recomputable |
| B | Expression tree | high, eval_trace MUST |
| C | with function delegation | layered, Grade C MUST NOT pose as plain-text recomputable |
Rules with function delegation (Grade C) MUST explicitly mark “contains non-recomputable function delegation” in gloss; the delegated function’s call input + output hash MUST enter the result hash’s preimage.
Function-delegation calling convention and registry (MUST, resolving the “no calling convention / no registry format” gap):
| Item | Convention |
|---|---|
| Function signature | name(params) -> returnType — name is a snake_case identifier, params comma-separated parameter names, returnType the return type; the signature is the function’s unique registry key |
| Registry entry | { signature, sandbox, timeout, deterministic, ... } — sandbox ∈ pure/network/filesystem, deterministic declares Guard-path evaluability, timeout the timeout cap |
| Hash preimage | a delegated call’s hash preimage MUST include: fn_id (= signature), version (function version), args_hash (JCS hash of the arguments), result_hash (result hash) — all four, so a Grade C call can be verified offline for “which version, what input, what output” |
Grading and tier constraint (MUST): Grade C (function delegation) MUST NOT be used at tier 0–2 (safety baseline) — safety-sensitive rules at tier 0–2 MUST use the pure Simple kernel (Grade A) and MUST NOT bypass the closed evaluation kernel via fn delegation; Grade C is limited to tier ≥3 business-panorama rules.
| Term | One-line definition |
|---|---|
| Entity | a rule subject type (agent/tool/task/workflow/human/guardian), the namespace for field references (§3) |
| Rule | a when → then decision unit |
| DO (Decision Object) | the cryptographic audit record of a single evaluation (hashable, independently verifiable); distinct from the Evaluation Result (the runtime output of §7.0.3) — the DO is the canonical serialized form of the evaluation result (§8.2a field order + JCS RFC 8785 + SHA-256), while the Evaluation Result is the engine’s runtime return |
| genesis | the chain-start audit record generated for initial at state-block load (initial snapshot + document canonical-tree hash, §6a.5) |
| transition_error | a first-class on-chain record produced by a transition guard EvaluationError (does not apply set, does not increment version, does not move head, §6a.5) |
| event (event object) | the transition trigger input { event_id, on, at, actor, payload } (§6a.7) |
| actor | the authenticated event-source identifier (provided by the identity layer, enters the transition audit record, §6a.7) |
| when | a rule’s trigger condition (compiled to an expression tree) |
| then | the decision type after a rule matches (§6) |
| tier | rule level 0–5, low to high for constraint strength; tier 0–2 uses Simple, ≥3 may use Expression |
| ring | execution ring 0–3 (kernel/recovery/approval/advice); evaluation runs in ring order |
| Guard context | a safety-boundary evaluation context (the reference evaluate()); evaluation errors fail-close (E12), covering all tiers |
| non-Guard context | a simulation/analysis evaluation outside the safety boundary; tier 3-5 evaluation errors fold to false (E12) |
| override | override level critical > high > normal > low; only the DENY → ALLOW direction is allowed |
| expression tree | the evaluation semantic kernel (34 nodes, 10 groups); all three writing forms compile to it |
| canonical_tree | the canonical tree, the sole basis for hashing and recomputation (§8.2) |
| gloss | the natural-language readable projection deterministically generated from the tree (§5.5) |
| eval_trace | the node-level evaluation trace (recomputable derived product, does not enter the hash, E6) |
| eval_warnings | non-fatal warnings during evaluation (E3) |
| errored | whether evaluation errored (E3): EvaluationError (division by zero / invalid date / arity / type-mismatched arithmetic [string or object into arithmetic]) → true (even though E12 folds to false); type-mismatched comparison, null propagation, and missing-field arithmetic → false (the latter folds to unknown, not an error) |
| temporal_state | the temporal state: within/rate sliding-window state (stateful operators, counter semantics) — distinct from “authority state” (state block) |
| state block | the authority state: the named state machine declared by §6a (the state top-level block + transitions rules) — distinct from the “temporal state” temporal_state; the two do not share a namespace |
| state variable | a named state declared by the state block (§6a), state.<name> namespace, updatable only by transition rules |
| state space | the full set of state variables and their enum values declared by a document (finite, capped) |
| state transition | an event-triggered deterministic state change state.<name> ← value (the FSM’s F function, §6a.2) |
| controlled injection | engine-held inputs (as_of/temporal_state/state.*) not writable externally, updatable only by engine mechanisms (§6a.3, E1) |
| state_snapshot | the state snapshot at evaluation, entering the DO hash preimage (§6a.5) |
| enforcement boundary | the component (Action Guard / tool-call guard) that consumes the §6a decision and commits the gated side effect (§6a.8) |
| check/act atomicity | the §6a.8 obligation that no authorization-lineage state change commits between the authorization decision and the gated side effect’s commit |
| latest authoritative head | the current latest authoritative state anchor {state_version, transitions_head} for a document instance; its freshness across restart/recovery/replica boundaries requires an external anchor (§6a.9) |
| durable freshness anchor | the persistent anchor provided by the organization/deployment layer that establishes latest-authoritative-head freshness across restart/recovery/replica boundaries (monotonic epoch / durable anchor / signed checkpoint / consensus backing); the enforcement boundary uses it to determine whether restored state is sufficiently fresh (§6a.9) |
| authorization basis | where an authority comes from — a root grant or an independently verified re-authorization decision object that establishes/re-establishes authority for a subject; distinct from the authorization root (the principal entitled to establish it) and the authority chain (the lineage). |
| authorization root | the principal/authority entitled to establish/re-establish an authority; the actor of a transition that makes authority exercisable MUST be attributable to it (§6a.10) |
| delegation chain | the composition of multiple authorization relationships along “authorization root → intermediate node → authorized subject” (§6b) |
| effective authority | the authority a subject can actually exercise; MUST ⊆ the originating authority chain (§6b) |
| authority chain | the complete authorization lineage from the authorization root to the authorized subject; effective authority MUST be a subset of it (§6b) |
| delegated-authority invariants | the five delegation-chain security invariants INV-01~05 (non-amplification / provenance continuity / narrow-only inheritance / transitive revocation / capability boundary, §6b) |
| transition validity | the engine validates transitions: only declared ones execute, values belong to the enum, undeclared transitions do not execute (fail-closed) |
| as_of | the evaluation moment injected by the engine (UTC, E9) |
| context object | the evaluation input carrying the current state of entities (§7.0.1) |
| evaluation context (eval_profile.context) | one of the evaluation options (guard/analysis, the E12 folding direction, §8.2a.1b) — a different concept from the context object context (§7.0.1); the key name alone is shared |
| fallback decision | the metadata.decision fallback verdict when no rule matches (§2.2) |
| NFC | Unicode Normalization Form C (string normalization, E10) |
| ReDoS | regular-expression denial of service; the match node MUST guard via input-length cap + linear-time engine (§7.3(d)) |
| half-even | banker’s rounding (ROUND_HALF_EVEN), the E2 fixed-point output rounding |
| null propagation | the safe-failure semantics of returning false uniformly for missing fields (E11) |
| evaluation scope | the E2 fixed-point output precision (scale=14 + half-even string serialization); does not enter the canonical_tree hash |
| encoding scope | the §8.2 canonical serialization of number literals (a typed object {"n":"<decimal string>"}, not JCS number); enters the hash |
This specification’s relationship to existing rule / authorization standards (design trade-off comparison, non-normative):
| Standard | Domain | Relationship to ERDL |
|---|---|---|
| CEL (Common Expression Language, Google) | general expression evaluation | both are deterministic expression languages; CEL targets runtime evaluation, ERDL targets rule declaration + cryptographic audit (canonical_tree + DO hash, byte-for-byte cross-implementation verification), with evaluation as only one stage |
| DMN (Decision Model and Notation, OMG) | business decision modeling | both are declarative decisions; DMN targets business modeling and execution engines, ERDL emphasizes a deterministic kernel + independently recomputable hash evidence chain (§8), with an explicitly closed expression-tree kernel (30 operators + controlled extension profiles) |
| XACML (eXtensible Access Control Markup Language, OASIS) | access-control policy | both are declarative authorization policies; XACML targets a policy decision point (PDP) architecture, ERDL targets byte-for-byte-consistent evaluation semantics and hashable audit (§8.2a), without binding to a specific deployment topology |
| UCAN (User Controlled Authorization Networks) | decentralized capability authorization | both express authorization chains; UCAN carries capabilities via delegation chains, and ERDL’s §6b delegated-authority security model (INV-01~05) complements it — UCAN expresses “who grants what”, ERDL provides “verifiable adjudication of authorization state” (§6a FSM + audit chain) |
| OAuth 2.0 Token Exchange (RFC 8693) | token delegation | both involve delegation / impersonation; RFC 8693 defines the token-exchange protocol, ERDL defines no transport protocol, only deterministic semantics of authorization decisions and state — usable as an adjudication layer above RFC 8693 for “is this exchange allowed” |
Design trade-offs (key points): ERDL’s key trade-off is separating the deterministic kernel from the writing forms — multiple writing forms (Simple / Expression / decision table) compile to a single expression tree (E7), thereby gaining byte-for-byte-consistent evaluation results and hashes across implementations (§8), a property that CEL/DMN/XACML do not make a primary goal; the cost is expressiveness constrained by the closed kernel (30 operators + controlled extension profiles), with out-of-kernel needs routed to function delegation (Appendix D, Grade C).
| Version | Date | Changes |
|---|---|---|
| v2.3 | 2026-10-06 | §7.0.1b adds the fact-source typology (informative, five kinds: system_observed/llm_asserted/user_input/config/derived) + the fact-neutrality design note (provenance MUST NOT enter evaluation semantics or hash preimages); §7.0.1a field contract adds the informational provenance field; §8.2a.1b and Appendix E add the context double-meaning disambiguation note (context object vs evaluation context; renaming the key to evaluation_context is listed as a v2.4 candidate closure item) |
| v2.3 | 2026-10-06 | Body text aligned with the review-closure implementation (closure decisions written into the body): §8.2a.1 adds the DO sub-structure key-set/key-order table (S1) and the DO-hash domain-separation prefix erdl-eval-do-v3:; §8.2a.1a — the rule canonical object adds the five rule-text fields (M5), the rule_set_hash preimage adds on_indeterminate (S3), and the sort key is unified to (ring, priority, override, definition order); §8.2a.1b — eval_profile drops engine_id (S4, transport-envelope only); §8.2a.2 — transition records add fired/reason (M7); §7.0.3 — the output contract adds the indeterminate_rules row; §7.1a — adds the tier 0–2 interception lock (locked); §10.2 example ruleId → rule_id |
| v2.3 | 2026-10-06 | Review closure (S1–S6 + M1/M2/M5/M7/M10, breaking): S2 — the canonical encoding of number literals becomes a typed object {"n":"<decimal string>"} (eliminating number/string collisions); the tree hash gains a versioned domain-separation prefix erdl-tree-v3:; S1 — adds DO sub-structure key-set/key-order definitions + snake_case naming unification + the new indeterminate_rules; S4 — engine_id moves out of the DO hash preimage (envelope only); S3 — adds metadata.on_indeterminate (default REQUEST_HUMAN; tier 0–2 may configure DENY); unknown no longer fails open; S5 — decision merge becomes a set-based fold (permutation-invariant) + an outer ring MUST NOT cover an inner ring (ring(o) ≤ ring(r)) + WORKFLOW loses terminal status (only EMERGENCY_HALT short-circuits) + tier 0–2 interception locked; S6 — condition-level scope becomes writable + the counting key takes in rule name/window/scope values; M1/M2 — missing-field arithmetic and empty aggregation fold to unknown (not false/error); M5 — rule text (instruction/reason/correction/explanation/alternative) enters rule_set_hash; M7 — transition audit records add fired/reason fields; M10 — resource-limit violations are recorded in eval_warnings |
| v2.3 | 2026-10-06 | The fact field name is unified to context across the board (consistent with the engine parameter name and the RFC-002 governance-layer field name); §7.0.1 removes the context.context nesting (free fields attach directly at the top level) |
| v2.3 | 2026-10-04 | §6a engine implementation (reference implementation landing): adds state-definition.ts (load-time validation: state/transitions structure, same-variable conflict, state/event reference checks, guard whitelist) + state-machine.ts (event-injected FSM: eager FIFO, event_id de-dupe, atomic guard evaluation, genesis/transition/transition_error audit chain, on-demand state_snapshot); Evaluator gains stateMachine option + state.* controlled read + EvaluationResult.stateSnapshot |
| v2.3 | 2026-10-04 | §7.0.1a adds the field contract (EntityFieldContract) + §7.3(a) adds strict mode — declaring field types + comparison-node type mismatch records a warning under strict mode, fixing the audit findings “fail-open” and “silent false” |
| v2.3 | 2026-10-04 | §8.2a.1 evaluation-result DO field order adds context (the input context object, called context in RFC-002) — fixes the normative gap of “DO hash preimage missing the input context”, making “this decision, made against this input” independently recomputable; context sits first in the field order, semantically the “input → decision” closed loop (breaking: DO hash-preimage field-order change) |
| v2.2 | 2026-09-28 | §7.1 wording clarifications + override-absent sort alignment: item 2 states “sort by override level (critical > high > normal > low)”; item 6 unifies “when is the literal true”; override absent sorts as “default normal” (erdl-formal absent rank 4 → 2) — closes erdl-vectors#4 SPEC-REVIEW A/B/C |
| v2.2 | 2026-09-27 | §7.1 item 5 adds the tightening-direction clarification and fixes override-on-DENY semantics: DENY / ROLLBACK / QUARANTINE covering ALLOW (tightening) is the default consequence of “MUST NOT override to a less-safe state”, does not compare ring, and needs no override (an override on a tightening decision is inert); override acts only in the relaxing direction (DENY → ALLOW) — resolves the erdl-vectors PR#5 R08 interpretation ambiguity |
| v2.3 | 2026-10-07 | Removed AV-15 (re-authorization provenance, §6a.10) and AV-16 (multi-root basis-scoped revocation, §6b.4) together with §6b.4 — multi-root composition and re-authorization provenance are out of current scope; the delegated-authority adversarial family converges to AV-01~14 |
| v2.2 | 2026-09-17 | Landed conformance vectors AV-15 (re-authorization provenance, §6a.10) and AV-16 (multi-root basis-scoped revocation, §6b.4) — each a single vector with attack (→DENY) / legal (→ALLOW) sides; §6a.10/§6b.4 V-STATE cases annotated with their vector ids; §6b.5 adversarial vector family aligned from “AV-01~14 + AV-15/16” to “AV-01~16” (fixes the “two vectors” wording: AV-15/16 are not two independent DENY/ALLOW vectors but one vector each with both sides) |
| v2.2 | 2026-09-16 | §6b.4 new: basis-scoped revocation (multi-root composition) — a subject’s effective authority is the union over its currently-valid authorization bases; revoke(basis-X) removes exactly basis-X’s derivable authority (no less — full transitive closure of its downstream derivation; no more — other bases’ contribution survives); MUST NOT reduce a subject’s authority to a global per-subject revoked/authorized bit (forbids over-revocation and under-revocation); a surviving basis MUST NOT preserve authority unique to a revoked lineage; refines INV-04’s “downstream subtree” to be basis-relative; glossary adds authorization basis |
| v2.2 | 2026-09-15 | §6 decision types gain a design rationale: 13 types exist to maximize LLM value in the AI era, not simply allow/deny; five groups (allow-block / guide / human-in-the-loop / safety fallback / process) |
| v2.2 | 2026-09-15 | §6a.9 new: latest-authoritative-head freshness (anti-rollback, integration requirement) — successful replay verification does not establish currentness (distinguish integrity/provenance from freshness); before authorizing a security-sensitive side effect the enforcement/recovery boundary MUST establish that {state_version, transitions_head} is the latest authoritative head (not superseded), implementation-neutral (monotonic epoch / durable anchor / signed checkpoint / consensus); fail closed when freshness cannot be established; V-STATE adds the authorized@N/HN → revoke@N+1/HN+1 → restore historical prefix → replay succeeds → reject effect anti-rollback vector |
| v2.2 | 2026-09-15 | §6a.9 layering clarification (Finding 2 sign-off): the durable freshness anchor is provided by the organization/deployment layer; the fail-closed property is preserved — if the enforcement boundary cannot establish that the restored {state_version, transitions_head} is sufficiently fresh relative to the authoritative persistence state, authority-bearing effects MUST NOT proceed; successful replay/integrity verification is never sufficient evidence that authority is still current |
| v2.2 | 2026-09-15 | §6a.10 new: authorization-root provenance for establishing/re-establishing authority (integration requirement) — a transition that makes authority exercisable MUST carry authorization-root provenance (actor attributable to a principal entitled to establish it); re-authorization after revocation MUST have a new valid authorization basis; a descendant MUST NOT self-restore revoked authority; authorization-root eligibility is a boundary/organization-layer obligation (fail closed when unestablishable); V-STATE adds root-establish→revoke→non-root-re-authorize→attempt (DENY) and root-reestablish→ALLOW vectors |
| v2.2 | 2026-09-15 | §6b new: delegated-authority security model (organization behavior layer) — umbrella “delegation must never manufacture authority”; five invariants INV-01~05 (non-amplification / provenance continuity / narrow-only inheritance / transitive revocation / capability boundary), each = property + violation shape + normative assertion; mechanism-neutral revocation freshness; adversarial vector family AV-01~14 + AV-15/16 |
| v2.2 | 2026-09-14 | §6a.8 new: enforcement-boundary check/act atomicity (integration requirement) — for §6a-dependent security-sensitive side effects, the boundary MUST re-validate or close the synchronous boundary so no authorization-lineage state change commits between decision and effect; the engine exposes the re-validation primitive, the boundary discharges the obligation (E1 purity preserved); V-STATE adds the authorized@N → ALLOW@N → revoke@N+1 → attempt-effect fail-closed vector |
| v2.2 | 2026-09-12 | New §6a state blocks and state transitions (controlled state source): state/transitions optional top-level fields; controlled state injection (state.* reuses the field node, no new nodes); resource caps (≤4 variables/2–4 enums/≤256 combinations/≤32 transition rules/≤16 event names/≤8-key payload) |
| v2.2 | 2026-09-12 | State-transition audit closure: transition chain + snapshot + validity + provenance anchoring; state_snapshot extended to {values,state_version,transitions_head}, keys code-point-ascending by state-variable name + string NFC normalization |
| v2.2 | 2026-09-12 | Same-variable conflict decidable mutual-exclusion check ((0)-(4) sound constraints: unconditional-unique + top-level-conjunct-only proof basis, reject rather than silently accept) |
| v2.2 | 2026-09-12 | §6a.7 event and transition evaluation context: event object event_id/on/at/actor/payload; guards read only state.* + event.*, not free context |
| v2.2 | 2026-09-12 | Event-handling atomicity (evaluate guards one by one in definition order → stop at the first EvaluationError committing no set, fail-closed → commit all sets at once on full pass; order within one event must not affect the result); event injection authentication (actor enters the audit record, unauthenticated events rejected); concurrency serialization (event handling and evaluate are mutually exclusive); genesis record (initial generates an initial snapshot + canonical-tree hash) |
| v2.2 | 2026-09-12 | Load-time validation full set (any expression position referencing an undeclared state. |
| v2.2 | 2026-09-12 | decision renamed audit_as (audit carrier only, does not participate in evaluation/short-circuit, narrowed to {ALLOW,NOTIFY,DELEGATE,ESCALATE,REQUEST_HUMAN}); transitions gains enabled (default true) and reason constraint ([a-z][a-z0-9_]{0,31} + document-unique); state gains display_name (bilingual, gloss uses en falling back to name) |
| v2.2 | 2026-09-12 | transitions.when node whitelist (Simple conditions + time nodes; no quantifiers/arithmetic/aggregates/fn/within/rate); state machine has no time trigger (freshness via external sweeper or guard time comparison) |
| v2.2 | 2026-09-12 | §7.0.2 evaluation algorithm gains an events-happen-first declaration (step 0) and catch-all lazy two-pass, fixes the WORKFLOW cross-reference (state machine split: §6 workflow / §6a authority); §7.0.3 adds the state_snapshot output field (enters the hash preimage); E1 extends the authority-state snapshot as a controlled external input; glossary adds state variable / state space / state transition / controlled injection / state_snapshot / transition validity |
| v2.1 | 2026-09-12 | §8.2 pins the canonical encoding of number literals to JCS (RFC 8785) IEEE 754 number serialization (aligned with the reference implementation); distinguishes the evaluation convention (E2 fixed-point) from the encoding convention (§8.2 canonical serialization); E12 clarifies Guard-context semantics (a Guard context fail-closes for all tiers; a non-Guard context fail-closes tier≤2 and folds tier 3–5 to false); glossary adds non-Guard context / evaluation scope / encoding scope; §7.3(a) spells out the missing-field arithmetic split (comparison node→false, arith node→EvaluationError); §7.0.2/§7.0.3 aligned with E12 |
| v2.1 | 2026-09-10 | §7.3(c) clarifies conformance compares the scale-14 fixed-point value numerically (trailing-zero insensitive: "35" ≡ "35.0"), not the string spelling — the decimal-string form is an encoding, not the comparison unit; §7.3(a) extends the warning asymmetry to logic nodes (and/or over a non-boolean operand fold silently) and quantifiers (all/any/none over a non-array operand record type_mismatch); §7.3(b) clarifies quantifier non-array over; §7.3(d) clarifies the ReDoS fold (false + regex_re_dos, errored: false); §7.3(g) new: E4 structural resource-limit violations throw (value: null + threw: true), E5 exclusivity records value: true; §5.5 adds gloss rendering details (not(eq) normalization, quoted string/list literals, parenthesized arithmetic); §7.3(a) clarifies the errored reading: in/string/length/aggregate record a type_mismatch warning but errored: false (a warning only, not an E3 EvaluationError) |
| v2.1 | 2026-09-09 | §7.3(a) annotates the warning asymmetry (comparison/between fold silently with no warning; in/string/length/aggregate record type_mismatch); §5.5 aligns gloss template wording to the renderer (in/between/length/match/epoch_ms/date_part/date_add/aggregate/quantifier/var); §5.5 pins gloss rendering to English canonical (G3 display_name takes the English value; Chinese template is a presentation-only optional projection); §7.2 E3 / §7.3(a) / Appendix E add the errored evaluation-error flag: EvaluationError (division by zero / invalid date / arity / type-mismatched arithmetic) → errored=true (even though E12 folds to false); type-mismatched comparison and null propagation → errored=false (not an error) |
| v2.1 | 2026-09-05 | §7.1 adds item 6: an empty-condition rule (catch-all/fallback) MUST NOT rewrite the decision established by an explicit-condition rule (in either direction); the fallback takes effect only when no explicit rule matches; §7.3(f) clarifies date-time input parsing is whole-second precision (fractional seconds not supported), aligned across implementations |
| v2.1 | 2026-09-04 | §7.3(d) clarifies the safe syntax subset as a regular language: backreferences (\1–\9, \k<name>) and lookaround ((?=)/(?!)/(?<=)/(?<!)) are forbidden; inline case flags are not provided (matching is always case-sensitive) |
| v2.1 | 2026-09-03 | §4.1 adds three optional fields — category (rule-level override), enabled (enable flag), correction (CORRECT fix text) — completing the field table and fixed order; §7.0.3 adds the primary_correction source cross-reference. Protocol erdl/v2 unchanged; rule-format version 2.0.0 → 2.1.0 (additive optional fields, non-breaking) |
| v2.0 | 2026-08-30 | Finalized |
concordia-python-expression), a spec-and-contract-only Python implementation that byte-verified all 240 V-ENGINE expression-layer vectors; its RESULTS.md recorded 16 spec ambiguities (A1–A16), four of which exposed real gaps now fixed.within/rate). His review of the evaluator surfaced the temporal_state evidence gap on state mutation and the total_evaluated count drift — each now fixed and covered by conformance vectors. The delegated-authority security invariants (INV-01–INV-05) and associated adversarial conformance vectors (AV-01–AV-14) were proposed by him and subsequently refined and developed through technical review and collaboration with OpenOBA, now underpinning OpenOBA’s multi-agent governance direction. He also contributed an independent Python runner (ravindra-annam-python-independent, a Python 3 stdlib spec-only expression-tree evaluator) for the delegated-authority conformance set, verifying AV-01~AV-14 (14/14). He also authored the first independent §7.1 resolution runner (PR #5): 13 neutral V-RESOLVE vectors (R01–R13) + a spec-only runner, whose derivation surfaced and resolved the tightening-direction boundary (R08/R13), now made explicit in §7.1 item 5.© 2026 Shenzhen Miaojing Technology Co., Ltd. · MIT License