rule-cascade 1.0.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """Rule Cascade: load, check and evaluate YAML/JSON business rule contracts.
2
+
3
+ This package is also the reference implementation of the specification. The other runtimes are
4
+ ports of it and must agree with it on every case in the conformance suite.
5
+
6
+ from rule_cascade import load, LoadError
7
+
8
+ rules = load(document, registry, loader) # raises LoadError
9
+ result = rules.evaluate({"entity": "Transfer", "operation": "create", "data": payload})
10
+ if result["decision"] == "deny": ...
11
+ """
12
+ from .cron import CronError, CronSchedule
13
+ from .evaluate import ENGINE_ERROR_CODE, ENGINE_ERROR_MESSAGE, RequestError, evaluate, missing_operators, request_problem
14
+ from .expressions import EvalError, evaluate_expression
15
+ from .holder import Reload, RuleSetHolder
16
+ from .ruleset import ChannelError, LoadError, RuleSet, load, manifest, resolve, schema_problems, static_problems
17
+ from .values import canonical, canon_num, equal, plain, render
18
+
19
+ __version__ = "1.0.0a2"
20
+ __all__ = [
21
+ "ChannelError", "CronError", "CronSchedule", "ENGINE_ERROR_CODE", "ENGINE_ERROR_MESSAGE", "EvalError", "LoadError",
22
+ "Reload", "RequestError", "RuleSet", "RuleSetHolder", "canonical",
23
+ "equal", "missing_operators", "request_problem",
24
+ "evaluate", "evaluate_expression", "load", "manifest", "plain", "render", "resolve", "schema_problems",
25
+ "static_problems",
26
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .engine import main
4
+
5
+ sys.exit(main())
rule_cascade/cron.py ADDED
@@ -0,0 +1,139 @@
1
+ """A five-field cron schedule: ``minute hour day-of-month month day-of-week``.
2
+
3
+ Each field is ``*``, a number, a range ``a-b``, a step ``*/n``, ``a-b/n`` or ``a/n`` (a to the maximum),
4
+ or a comma-separated list of those. Day of week is 0 to 7, where 0 and 7 are both Sunday. Names
5
+ (``JAN``, ``MON``) and macros (``@daily``) are not supported. When day of month and day of week are
6
+ both restricted, a day matches when either matches; a field that begins with ``*`` is not restricted.
7
+ A schedule that can never fire, such as ``0 0 30 2 *``, is refused.
8
+
9
+ The TypeScript, Java and Go runtimes implement the same semantics; ``tools/cron-cases.json`` is the
10
+ table all four are tested against.
11
+
12
+ schedule = CronSchedule("*/5 * * * *")
13
+ schedule.next(datetime.now(timezone.utc)) # the next fire time, in the zone given
14
+ """
15
+ import datetime as _dt
16
+ import re
17
+
18
+ __all__ = ["CronSchedule", "CronError"]
19
+
20
+ _NAMES = ("minute", "hour", "day of month", "month", "day of week")
21
+ _MIN = (0, 0, 1, 1, 0)
22
+ _MAX = (59, 23, 31, 12, 7)
23
+ _DAYS_IN_MONTH = (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
24
+ _SEARCH_YEARS = 30 # bounds the search for the next fire time; only reachable through a bug
25
+ _DIGITS = re.compile(r"[0-9]+\Z")
26
+
27
+
28
+ class CronError(ValueError):
29
+ """The expression is not a five-field cron schedule this module accepts."""
30
+
31
+
32
+ def _digits(text):
33
+ return min(int(text), 100000)
34
+
35
+
36
+ def _field(text, index):
37
+ name, low, high = _NAMES[index], _MIN[index], _MAX[index]
38
+ allowed = [False] * (high + 1)
39
+
40
+ def number(s):
41
+ if not _DIGITS.match(s):
42
+ raise CronError(f"{name}: '{s}' is not a number")
43
+ n = _digits(s)
44
+ if n < low or n > high:
45
+ raise CronError(f"{name}: {n} is outside {low}-{high}")
46
+ return n
47
+
48
+ for part in text.split(","):
49
+ pieces = part.split("/")
50
+ if len(pieces) > 2:
51
+ raise CronError(f"{name}: '{part}' has more than one step")
52
+ base, step = pieces[0], 1
53
+ if len(pieces) == 2:
54
+ if not _DIGITS.match(pieces[1]) or _digits(pieces[1]) == 0:
55
+ raise CronError(f"{name}: step '{pieces[1]}' is not a positive number")
56
+ step = _digits(pieces[1])
57
+ if base == "*":
58
+ start, end = low, high
59
+ elif "-" in base:
60
+ bounds = base.split("-")
61
+ if len(bounds) != 2:
62
+ raise CronError(f"{name}: '{base}' is not a range")
63
+ start, end = number(bounds[0]), number(bounds[1])
64
+ if start > end:
65
+ raise CronError(f"{name}: range '{base}' is reversed")
66
+ else:
67
+ start = number(base)
68
+ end = high if len(pieces) == 2 else start # a/n runs from a to the maximum
69
+ for v in range(start, end + 1, step):
70
+ allowed[v] = True
71
+ return allowed
72
+
73
+
74
+ class CronSchedule:
75
+ """A parsed cron expression. Immutable."""
76
+
77
+ def __init__(self, expression):
78
+ if not isinstance(expression, str):
79
+ raise CronError("a cron expression is a string")
80
+ parts = [p for p in re.split(r"[ \t]+", expression.strip()) if p]
81
+ if len(parts) != 5:
82
+ raise CronError(f"'{expression}': a cron expression has five fields "
83
+ f"(minute hour day-of-month month day-of-week), found {len(parts)}")
84
+ self.expression = expression
85
+ self._minutes, self._hours, self._days, self._months, self._weekdays = (
86
+ _field(p, i) for i, p in enumerate(parts))
87
+ if self._weekdays[7]:
88
+ self._weekdays[0] = True
89
+ self._day_star = parts[2].startswith("*")
90
+ self._weekday_star = parts[4].startswith("*")
91
+ if not self._day_star and self._weekday_star:
92
+ possible = any(self._months[m] and self._days[d]
93
+ for m in range(1, 13) for d in range(1, _DAYS_IN_MONTH[m - 1] + 1))
94
+ if not possible:
95
+ raise CronError(f"'{expression}' never fires: no chosen month has the chosen days")
96
+
97
+ def __repr__(self):
98
+ return f"CronSchedule({self.expression!r})"
99
+
100
+ def _day_matches(self, t):
101
+ by_day = self._days[t.day]
102
+ by_weekday = self._weekdays[t.isoweekday() % 7] # isoweekday: Monday 1 ... Sunday 7
103
+ return (by_day and by_weekday) if (self._day_star or self._weekday_star) else (by_day or by_weekday)
104
+
105
+ def _next_wall(self, after):
106
+ """The first matching wall-clock minute strictly after ``after`` (a naive datetime)."""
107
+ t = after.replace(second=0, microsecond=0) + _dt.timedelta(minutes=1)
108
+ limit = after.year + _SEARCH_YEARS
109
+ while t.year <= limit:
110
+ if not self._months[t.month]:
111
+ t = t.replace(day=1, hour=0, minute=0) # the day first: 31 January has no 31 February
112
+ t = t.replace(year=t.year + 1, month=1) if t.month == 12 else t.replace(month=t.month + 1)
113
+ elif not self._day_matches(t):
114
+ t = t.replace(hour=0, minute=0) + _dt.timedelta(days=1)
115
+ elif not self._hours[t.hour]:
116
+ t = t.replace(minute=0) + _dt.timedelta(hours=1)
117
+ elif not self._minutes[t.minute]:
118
+ t += _dt.timedelta(minutes=1)
119
+ else:
120
+ return t
121
+ raise CronError(f"'{self.expression}' does not fire within {_SEARCH_YEARS} years")
122
+
123
+ def next(self, after, tz=None):
124
+ """The first fire time strictly after ``after``, an aware datetime, on the wall clock of ``tz``
125
+ (default: the zone of ``after``). Seconds are ignored. A wall-clock time that does not exist in
126
+ the zone (the hour skipped in spring) is skipped; one that happens twice fires once, at the first
127
+ occurrence. A naive ``after`` is read as UTC."""
128
+ if after.tzinfo is None:
129
+ after = after.replace(tzinfo=_dt.timezone.utc)
130
+ tz = tz or after.tzinfo
131
+ start = after.replace(second=0, microsecond=0)
132
+ wall = start.astimezone(tz).replace(tzinfo=None)
133
+ while True:
134
+ wall = self._next_wall(wall)
135
+ first = wall.replace(tzinfo=tz, fold=0) # fold=0: the earlier of two instants in an overlap
136
+ if first.astimezone(_dt.timezone.utc).astimezone(tz).replace(tzinfo=None) != wall:
137
+ continue # in a gap: the zone never shows this time
138
+ if first > start:
139
+ return first
rule_cascade/engine.py ADDED
@@ -0,0 +1,218 @@
1
+ """The engine protocol: JSON Lines over standard input and output (specification section 13).
2
+
3
+ One JSON object per line in, one per line out, in the same order. It is how a program in any language,
4
+ on any operating system, drives an engine it cannot link to, and how the conformance suite certifies one.
5
+
6
+ python -m rule_cascade engine < requests.jsonl
7
+ """
8
+ import json
9
+ import sys
10
+
11
+ from . import __version__
12
+ from .evaluate import request_problem
13
+ from .expressions import MAX_VALUE_DEPTH, EvalError, evaluate_expression, too_deep
14
+ from .ruleset import BUNDLE_VERSION, LoadError, RuleSet, load
15
+
16
+ SPEC_VERSION = "1.0.0"
17
+
18
+
19
+ class ProtocolError(Exception):
20
+ def __init__(self, code, message, **extra):
21
+ super().__init__(message)
22
+ self.error = dict(code=code, message=message, **extra)
23
+
24
+
25
+ def _need(request, key, kind):
26
+ value = request.get(key)
27
+ if not isinstance(value, kind):
28
+ raise ProtocolError("BAD_REQUEST", f"'{key}' is missing or has the wrong type")
29
+ return value
30
+
31
+
32
+ def _optional(request, key):
33
+ """An optional object member: absent and null are the same."""
34
+ value = request.get(key)
35
+ if value is not None and not isinstance(value, dict):
36
+ raise ProtocolError("BAD_REQUEST", f"'{key}' must be an object")
37
+ return value
38
+
39
+
40
+ def _not_json(token):
41
+ raise ValueError(f"{token} is not JSON")
42
+
43
+
44
+ def _double(text):
45
+ value = float(text)
46
+ if value in (float("inf"), float("-inf")):
47
+ raise ValueError(f"{text} is outside the range of a double")
48
+ return value
49
+
50
+
51
+ def _whole(text):
52
+ value = int(text)
53
+ if abs(value) > 2 ** 53:
54
+ _double(text) # beyond 2^53 a whole number is still read; beyond the doubles it is refused
55
+ return value
56
+
57
+
58
+ class Engine:
59
+ """Holds loaded rulesets between requests. `operators` are the host's custom operators."""
60
+
61
+ def __init__(self, operators=None):
62
+ self.operators = operators or {}
63
+ self.rulesets = {}
64
+
65
+ def _check_source(self, request):
66
+ """Members first: a malformed request is BAD_REQUEST whatever rulesets are loaded."""
67
+ if "bundle" in request:
68
+ _need(request, "bundle", dict)
69
+ elif "manifest" in request:
70
+ _need(request, "manifest", dict)
71
+ else:
72
+ _need(request, "ruleset", str)
73
+ channel = request.get("channel") # optional: absent and null are the same
74
+ if channel not in (None, "server", "client"):
75
+ raise ProtocolError("BAD_REQUEST", "'channel' must be server or client")
76
+ return channel
77
+
78
+ def _ruleset(self, request):
79
+ try:
80
+ if "bundle" in request:
81
+ return RuleSet.from_bundle(request["bundle"])
82
+ if "manifest" in request:
83
+ return RuleSet.from_manifest(request["manifest"])
84
+ except LoadError as err:
85
+ raise ProtocolError("LOAD_FAILED", str(err), problems=err.problems)
86
+ if request["ruleset"] not in self.rulesets:
87
+ raise ProtocolError("UNKNOWN_RULESET", f"ruleset {request['ruleset']} has not been loaded")
88
+ return self.rulesets[request["ruleset"]]
89
+
90
+ @staticmethod
91
+ def _manifest(ruleset, channel):
92
+ """The manifest to use: the one asked for; by default the server's, or the only one there is."""
93
+ if channel is None:
94
+ channel = "server" if "server" in ruleset.channels else ruleset.channels[0]
95
+ if channel not in ruleset.channels:
96
+ raise ProtocolError("CHANNEL_UNAVAILABLE", f"ruleset {ruleset.id} was loaded without a {channel} manifest")
97
+ return channel
98
+
99
+ def handle(self, request):
100
+ """One request object in, one response object out. Never raises."""
101
+ response = {}
102
+ try:
103
+ if not isinstance(request, dict):
104
+ raise ProtocolError("BAD_REQUEST", "a request is a JSON object")
105
+ if "id" in request:
106
+ response["id"] = request["id"]
107
+ response.update(ok=True, result=self._dispatch(request))
108
+ except ProtocolError as err:
109
+ response.update(ok=False, error=err.error)
110
+ except RecursionError: # a member nested beyond what the checks before evaluation reach
111
+ response.update(ok=False, error={"code": "BAD_REQUEST", "message": "a member is nested too deeply"})
112
+ return response
113
+
114
+ def _dispatch(self, request):
115
+ command = request.get("command")
116
+ if command == "version":
117
+ return {"engine": "rule-cascade-python", "engineVersion": __version__, "ruleCascade": SPEC_VERSION,
118
+ "bundle": BUNDLE_VERSION, "levels": ["evaluator", "compiler"],
119
+ "operators": sorted(self.operators)}
120
+ if command == "compile":
121
+ document = _need(request, "document", dict)
122
+ registry = dict(_optional(request, "registry") or {})
123
+ schemas = _optional(request, "schemaDocuments")
124
+ try:
125
+ meta = document.get("metadata")
126
+ if isinstance(meta, dict) and isinstance(meta.get("id"), str):
127
+ registry.setdefault(meta["id"], document)
128
+ return load(document, registry, schemas.get if schemas is not None else None).bundle()
129
+ except LoadError as err:
130
+ raise ProtocolError("LOAD_FAILED", str(err), problems=err.problems)
131
+ if command == "load":
132
+ _need(request, "manifest" if "manifest" in request and "bundle" not in request else "bundle", dict)
133
+ ruleset = self._ruleset(request)
134
+ self.rulesets[ruleset.id] = ruleset # replaces whatever was loaded under that id
135
+ return {"ruleset": ruleset.id, "version": ruleset.version, "checksum": ruleset.checksum,
136
+ "channels": ruleset.channels, "missingOperators": ruleset.missing_operators(self.operators)}
137
+ if command == "manifest":
138
+ channel = self._check_source(request)
139
+ ruleset = self._ruleset(request)
140
+ return ruleset.manifest(self._manifest(ruleset, channel))
141
+ if command == "evaluate":
142
+ channel = self._check_source(request)
143
+ why = request_problem(request.get("request"))
144
+ if why:
145
+ raise ProtocolError("BAD_REQUEST", f"'request': {why}")
146
+ ruleset = self._ruleset(request)
147
+ return ruleset.evaluate(request["request"], self._manifest(ruleset, channel), self.operators)
148
+ if command == "expression":
149
+ if "expr" not in request:
150
+ raise ProtocolError("BAD_REQUEST", "'expr' is missing")
151
+ env, functions = _optional(request, "env"), _optional(request, "functions")
152
+ for root, value in (env or {}).items():
153
+ if too_deep(value):
154
+ raise ProtocolError("BAD_REQUEST", f"'env.{root}' is nested more than {MAX_VALUE_DEPTH} deep")
155
+ try:
156
+ return evaluate_expression(request["expr"], env, functions, self.operators)
157
+ except EvalError as err:
158
+ raise ProtocolError("EVALUATION_ERROR", str(err))
159
+ raise ProtocolError("BAD_REQUEST", f"unknown command {command!r}")
160
+
161
+
162
+ def handle_line(self, line):
163
+ """One line of text in, one response object out."""
164
+ try:
165
+ request = json.loads(line, parse_constant=_not_json, parse_float=_double, parse_int=_whole)
166
+ except (ValueError, RecursionError) as err: # RecursionError: nested beyond what the parser handles
167
+ return {"ok": False, "error": {"code": "BAD_REQUEST", "message": f"not JSON: {err}"}}
168
+ return self.handle(request)
169
+
170
+
171
+ def serve(lines, out, operators=None):
172
+ """Answer requests until the input ends."""
173
+ engine = Engine(operators)
174
+ for line in lines:
175
+ if not line.strip():
176
+ continue
177
+ out.write(json.dumps(engine.handle_line(line), ensure_ascii=False, separators=(",", ":")) + "\n")
178
+ out.flush()
179
+
180
+
181
+ def _luhn(text):
182
+ if not isinstance(text, str) or len(text) < 2 or not all("0" <= c <= "9" for c in text):
183
+ return False
184
+ total = 0
185
+ for i, c in enumerate(reversed(text)):
186
+ d = int(c) * (2 if i % 2 else 1)
187
+ total += d - 9 if d > 9 else d
188
+ return total % 10 == 0
189
+
190
+
191
+ def _reverse(text):
192
+ if not isinstance(text, str):
193
+ raise TypeError("x-test-reverse expects a string")
194
+ return text[::-1]
195
+
196
+
197
+ def _sum(*numbers):
198
+ total = 0.0 # ordinary IEEE 754 doubles, added left to right
199
+ for x in numbers:
200
+ if isinstance(x, bool) or not isinstance(x, (int, float)):
201
+ raise TypeError("x-test-sum expects numbers")
202
+ total += float(x)
203
+ return total
204
+
205
+
206
+ # The three operators every conformance runner registers (conformance/README.md).
207
+ CONFORMANCE_OPERATORS = {"x-test-reverse": _reverse, "x-test-sum": _sum, "x-luhn": _luhn}
208
+
209
+
210
+ def main(argv=None):
211
+ argv = sys.argv[1:] if argv is None else argv
212
+ if not argv or argv[0] != "engine" or set(argv[1:]) - {"--conformance-operators"}:
213
+ print("usage: python -m rule_cascade engine [--conformance-operators]", file=sys.stderr)
214
+ return 2
215
+ sys.stdin.reconfigure(encoding="utf-8")
216
+ sys.stdout.reconfigure(encoding="utf-8", newline="\n")
217
+ serve(sys.stdin, sys.stdout, CONFORMANCE_OPERATORS if "--conformance-operators" in argv else None)
218
+ return 0
@@ -0,0 +1,260 @@
1
+ """Evaluation of one operation on one entity against a manifest.
2
+
3
+ This module is the executable definition of section 8 of spec/v1/SPECIFICATION.md.
4
+ """
5
+ import json
6
+
7
+ from .expressions import FUNCTIONS, MAX_VALUE_DEPTH, OPERATORS, EvalError, boolean, ev, lookup, too_deep
8
+ from .ruleset import PLACEHOLDER, VIEW_KEYS
9
+ from .values import dec, equal, plain, render
10
+
11
+ ENGINE_ERROR_CODE = "RULE-EVALUATION-ERROR"
12
+ ENGINE_ERROR_MESSAGE = "This rule could not be evaluated."
13
+
14
+
15
+ def fill(template, args):
16
+ return PLACEHOLDER.sub(lambda m: render(args[m.group(1)]) if m.group(1) in args else m.group(0), template)
17
+
18
+
19
+ def pointer_path(root, pointer):
20
+ return root + pointer.replace("/", ".")
21
+
22
+
23
+ def set_pointer(obj, pointer, value):
24
+ *parents, last = pointer.strip("/").split("/")
25
+ for p in parents:
26
+ if not isinstance(obj.get(p), dict):
27
+ obj[p] = {}
28
+ obj = obj[p]
29
+ obj[last] = value
30
+
31
+
32
+ def _is(kind):
33
+ return lambda v: v is None or isinstance(v, kind)
34
+
35
+
36
+ def _strings(v):
37
+ return v is None or (isinstance(v, list) and all(isinstance(x, str) for x in v))
38
+
39
+
40
+ def request_problem(request):
41
+ """Why `request` is not an evaluation request, or None. Checked before anything is evaluated,
42
+ so a malformed request is refused instead of half-evaluated. Optional members may be null."""
43
+ if not isinstance(request, dict):
44
+ return "a request is an object"
45
+ for key in ("entity", "operation"):
46
+ if not isinstance(request.get(key), str):
47
+ return f"'{key}' must be a string"
48
+ for key, ok, what in (("data", _is(dict), "an object"), ("original", _is(dict), "an object"),
49
+ ("actor", _is(dict), "an object"), ("ctx", _is(dict), "an object"),
50
+ ("view", _is(dict), "an object"), ("resolutions", _is(list), "a list"),
51
+ ("trigger", _is(str), "a string"), ("locale", _is(str), "a string")):
52
+ if not ok(request.get(key)):
53
+ return f"'{key}' must be {what}"
54
+ for key in ("data", "original", "actor", "ctx"):
55
+ if too_deep(request.get(key)):
56
+ return f"'{key}' is nested more than {MAX_VALUE_DEPTH} deep"
57
+ if not _strings((request.get("actor") or {}).get("roles")):
58
+ return "'actor.roles' must be a list of strings"
59
+ for key in VIEW_KEYS:
60
+ if not _is(str)((request.get("view") or {}).get(key)):
61
+ return f"'view.{key}' must be a string"
62
+ for r in request.get("resolutions") or []:
63
+ if not (isinstance(r, dict) and isinstance(r.get("rule"), str) and isinstance(r.get("type"), str)
64
+ and _is(str)(r.get("justification"))):
65
+ return "a resolution is an object with a string 'rule' and 'type' and an optional string 'justification'"
66
+ return None
67
+
68
+
69
+ def locale_chain(default, wanted):
70
+ """Catalogs to merge, least specific first: the default locale, then every prefix of the requested
71
+ tag. `fr-CA` reads en, fr, fr-CA: a regional catalog only needs the messages that differ."""
72
+ parts = wanted.split("-") if wanted else []
73
+ return [default] + ["-".join(parts[:i]) for i in range(1, len(parts) + 1)]
74
+
75
+
76
+ class RequestError(ValueError):
77
+ """The evaluation request does not have the shape of specification section 8."""
78
+
79
+
80
+ def missing_operators(mf, operators=None):
81
+ """Custom operators the manifest's rules need that the host has not registered. Check it at start-up:
82
+ a missing operator does not fail the load, it fails every rule that uses it, closed."""
83
+ return sorted(name for name in set(mf.get("operators") or []) if not callable((operators or {}).get(name)))
84
+
85
+
86
+ def evaluate(mf, request, operators=None):
87
+ """Pure: no I/O, no clock, no randomness. `operators` maps custom operator names to host functions."""
88
+ why = request_problem(request)
89
+ if why:
90
+ raise RequestError(why)
91
+ entity, operation = request["entity"], request["operation"]
92
+ data = json.loads(json.dumps(request.get("data") or {})) # working copy; compute rules write into it
93
+ actor = request.get("actor") or {}
94
+ env = {"data": data, "original": request.get("original"), "actor": actor,
95
+ "ctx": request.get("ctx") or {}, "params": mf.get("params") or {},
96
+ FUNCTIONS: mf.get("functions") or {}, OPERATORS: operators or {}}
97
+ client = mf["channel"] == "client"
98
+ wanted = ("client", "both") if client else ("server", "both")
99
+ trigger = request.get("trigger") if client else None
100
+ view = request.get("view") or {}
101
+ field_types = (mf.get("fieldTypes") or {}).get(entity, {})
102
+
103
+ def selected(r):
104
+ target = r["target"]
105
+ return (r.get("enabled", True)
106
+ and target.get("entity", entity) == entity
107
+ and operation in r["operations"]
108
+ and r.get("enforcement", "both") in wanted
109
+ and (trigger is None or trigger in r.get("triggers", ["submit"]))
110
+ # a view narrows evaluation to one place in the UI; rules that name no place always apply
111
+ and all(view.get(k) is None or target.get(k) is None or view[k] == target[k] for k in VIEW_KEYS))
112
+
113
+ rules = [r for r in mf["rules"] if selected(r)]
114
+ messages = mf.get("messages") or {}
115
+ catalog = {}
116
+ for locale in locale_chain(mf.get("defaultLocale", "en"), request.get("locale")):
117
+ catalog.update(messages.get(locale, {}))
118
+ resolutions = {(x["rule"], x["type"]): x for x in request.get("resolutions") or []}
119
+ roles = set(actor.get("roles") or [])
120
+ findings, effects, commands = [], [], []
121
+
122
+ def engine_error(rule, err):
123
+ findings.append({"rule": rule["id"], "code": ENGINE_ERROR_CODE, "severity": "error",
124
+ "message": ENGINE_ERROR_MESSAGE, "fields": [], "blocking": True, "status": "open",
125
+ "resolution": "none", "source": rule["origin"], "detail": str(err)})
126
+
127
+ def priority(rule): # a number like any other: the double nearest to what was written
128
+ return dec(rule.get("priority", 0))
129
+
130
+ def applies(rule, scope):
131
+ return "when" not in rule or boolean(ev(rule["when"], scope))
132
+
133
+ def of_kind(kind): # stable: equal priorities keep document order
134
+ return sorted((r for r in rules if r["kind"] == kind), key=lambda r: -priority(r))
135
+
136
+ def loses(previous_rule, rule, what):
137
+ """Same target, different value. True = this rule loses quietly; raises when it is a real conflict."""
138
+ if mf.get("conflictPolicy", "fail") == "fail" or priority(previous_rule) == priority(rule):
139
+ raise EvalError(f"conflicting {what} from {previous_rule['id']}")
140
+ return True
141
+
142
+ # Phase 1: compute
143
+ written = {}
144
+ for r in of_kind("compute"):
145
+ try:
146
+ if not applies(r, env):
147
+ continue
148
+ for a in r["assign"]:
149
+ if a.get("mode", "default") == "default" and lookup(pointer_path("data", a["field"]), env) is not None:
150
+ continue
151
+ value = plain(ev(a["value"], env))
152
+ if a["field"] in written:
153
+ if equal(written[a["field"]][0], value) or loses(written[a["field"]][1], r, a["field"]):
154
+ continue
155
+ written[a["field"]] = (value, r)
156
+ set_pointer(data, a["field"], value)
157
+ effects.append({"type": "value", "field": a["field"], "value": value, "rule": r["id"]})
158
+ except EvalError as err:
159
+ engine_error(r, err)
160
+
161
+ # Phase 2: state
162
+ state = {}
163
+ for r in of_kind("state"):
164
+ try:
165
+ if not applies(r, env):
166
+ continue
167
+ # A rule applies all of its effects or, when one of them is a conflict, none of them:
168
+ # the outcome never depends on the order of the members of `set`.
169
+ staged = {}
170
+ for e in r["effects"]:
171
+ for prop, val in e["set"].items():
172
+ key = (e["field"], prop)
173
+ previous = staged.get(key) or state.get(key)
174
+ if previous and (equal(previous[0], val) or loses(previous[1], r, f"{prop} of {e['field']}")):
175
+ continue
176
+ staged[key] = (val, r)
177
+ state.update(staged)
178
+ except EvalError as err:
179
+ engine_error(r, err)
180
+ grouped = {}
181
+ for (field, prop), (val, r) in state.items():
182
+ grouped.setdefault((field, r["id"]), {})[prop] = val
183
+ effects += [{"type": "state", "field": f, "set": s, "rule": rid} for (f, rid), s in grouped.items()]
184
+
185
+ # Phase 3: validation
186
+ for r in of_kind("validation"):
187
+ try:
188
+ target = r["target"]
189
+ if "type" in target:
190
+ # once per field bound to the type, in pointer order, with `value` and `field` in scope
191
+ pointers = sorted(p for p, t in field_types.items() if t == target["type"])
192
+ scopes = [(dict(env, value=lookup(pointer_path("data", p), env), field=p), [p]) for p in pointers]
193
+ elif "forEach" in r:
194
+ coll = lookup(pointer_path("data", r["forEach"]), env)
195
+ if coll is None:
196
+ coll = []
197
+ if not isinstance(coll, list):
198
+ raise EvalError(f"forEach {r['forEach']} is not a list")
199
+ own = [target["field"]] if "field" in target else target.get("fields", [])
200
+ scopes = [(dict(env, item=x), [f"{r['forEach']}/{i}{p}" for p in own]) for i, x in enumerate(coll)]
201
+ else:
202
+ scopes = [(env, [target["field"]] if "field" in target else list(target.get("fields", [])))]
203
+ for scope, fields in scopes:
204
+ if not applies(r, scope) or boolean(ev(r["assert"], scope)):
205
+ continue
206
+ spec = r["finding"]
207
+ args = {k: plain(ev(x, scope)) for k, x in spec.get("args", {}).items()}
208
+ acceptance = r.get("acceptance", {})
209
+ severity = r["severity"]
210
+ status, resolution = "open", "none"
211
+ if severity == "error" and acceptance.get("allowed"):
212
+ resolution = "accept-risk"
213
+ given = resolutions.get((r["id"], "accept-risk"))
214
+ role_ok = not acceptance.get("roles") or bool(roles & set(acceptance["roles"]))
215
+ justified = acceptance.get("justification", "required") == "none" or bool(
216
+ (given or {}).get("justification"))
217
+ if given and role_ok and justified:
218
+ status = "accepted"
219
+ elif severity == "warning" and r.get("acknowledgement") == "required":
220
+ resolution = "acknowledge"
221
+ if (r["id"], "acknowledge") in resolutions:
222
+ status = "acknowledged"
223
+ finding = {
224
+ "rule": r["id"], "code": spec["code"], "severity": severity,
225
+ "message": fill(catalog.get(spec["message"], spec["message"]), args),
226
+ "fields": fields,
227
+ "blocking": status == "open" and (severity == "error" or resolution == "acknowledge"),
228
+ "status": status, "resolution": resolution, "source": r["origin"],
229
+ }
230
+ location = {k: target[k] for k in VIEW_KEYS if k in target}
231
+ if location:
232
+ finding["location"] = location
233
+ if acceptance.get("roles"):
234
+ finding["acceptableBy"] = acceptance["roles"]
235
+ findings.append(finding)
236
+ except EvalError as err:
237
+ engine_error(r, err)
238
+
239
+ decision = "deny" if any(f["blocking"] for f in findings) else "allow"
240
+
241
+ # Phase 4: action - server only, and only when allowed. The host runs commands after it has persisted.
242
+ if decision == "allow" and not client:
243
+ for r in of_kind("action"):
244
+ try:
245
+ if not applies(r, env):
246
+ continue
247
+ for c in r["commands"]:
248
+ cmd = {"name": c["name"], "type": c["type"], "rule": r["id"],
249
+ "idempotencyKey": ":".join(render(plain(ev(p, env))) for p in c["idempotencyKey"]),
250
+ "payload": {k: plain(ev(x, env)) for k, x in c.get("payload", {}).items()}}
251
+ if "ref" in c:
252
+ cmd["ref"] = c["ref"]
253
+ commands.append(cmd)
254
+ except EvalError as err:
255
+ engine_error(r, err)
256
+ decision, commands = "deny", []
257
+ break
258
+
259
+ return {"ruleset": mf["id"], "version": mf["version"], "checksum": mf["checksum"],
260
+ "decision": decision, "findings": findings, "effects": effects, "commands": commands}