flux-sim-engine 0.2.0__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.
Files changed (165) hide show
  1. flux_engine/__init__.py +22 -0
  2. flux_engine/_links.py +55 -0
  3. flux_engine/_vendor/__init__.py +5 -0
  4. flux_engine/_vendor/flux_spec/NOTICE +19 -0
  5. flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
  6. flux_engine/_vendor/flux_spec/__init__.py +9 -0
  7. flux_engine/_vendor/flux_spec/bundle.py +410 -0
  8. flux_engine/_vendor/flux_spec/enforce.py +316 -0
  9. flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
  10. flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
  11. flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
  12. flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
  13. flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
  14. flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
  15. flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
  16. flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
  17. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
  18. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
  19. flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
  20. flux_engine/_vendor/flux_spec/validate.py +537 -0
  21. flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
  22. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
  23. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
  24. flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
  25. flux_engine/_vocabulary.py +141 -0
  26. flux_engine/accuracy/__init__.py +5 -0
  27. flux_engine/accuracy/_scoring/__init__.py +24 -0
  28. flux_engine/accuracy/accuracy_record.py +96 -0
  29. flux_engine/accuracy/policy.py +158 -0
  30. flux_engine/accuracy/records.py +264 -0
  31. flux_engine/accuracy/store.py +169 -0
  32. flux_engine/api/__init__.py +163 -0
  33. flux_engine/api/accuracy.py +209 -0
  34. flux_engine/api/agentic.py +134 -0
  35. flux_engine/api/calibration.py +125 -0
  36. flux_engine/api/claims.py +93 -0
  37. flux_engine/api/digests.py +73 -0
  38. flux_engine/api/emit.py +78 -0
  39. flux_engine/api/expressions.py +72 -0
  40. flux_engine/api/fidelity.py +110 -0
  41. flux_engine/api/lineage.py +78 -0
  42. flux_engine/api/stages.py +242 -0
  43. flux_engine/api/verdict.py +108 -0
  44. flux_engine/calibrate/__init__.py +5 -0
  45. flux_engine/calibrate/declared.py +253 -0
  46. flux_engine/calibrate/provenance.py +113 -0
  47. flux_engine/cli/__init__.py +2146 -0
  48. flux_engine/cli/__main__.py +17 -0
  49. flux_engine/cli/_documents.py +226 -0
  50. flux_engine/cli/_hints.py +118 -0
  51. flux_engine/cli/_init.py +152 -0
  52. flux_engine/cli/_progress.py +124 -0
  53. flux_engine/concordance/__init__.py +127 -0
  54. flux_engine/concordance/boundary/__init__.py +78 -0
  55. flux_engine/concordance/boundary/merkle.py +287 -0
  56. flux_engine/concordance/boundary/slice.py +429 -0
  57. flux_engine/concordance/boundary/statistic.py +561 -0
  58. flux_engine/concordance/boundary/store.py +334 -0
  59. flux_engine/concordance/boundary/vault.py +357 -0
  60. flux_engine/concordance/compiler.py +542 -0
  61. flux_engine/concordance/errors.py +189 -0
  62. flux_engine/concordance/gate.py +525 -0
  63. flux_engine/concordance/predicate.py +1217 -0
  64. flux_engine/concordance/record.py +554 -0
  65. flux_engine/concordance/verifier.py +583 -0
  66. flux_engine/core/__init__.py +5 -0
  67. flux_engine/core/artifact.py +99 -0
  68. flux_engine/core/canonical.py +369 -0
  69. flux_engine/core/clock.py +437 -0
  70. flux_engine/core/digest.py +288 -0
  71. flux_engine/core/errors.py +277 -0
  72. flux_engine/core/fixedpoint.py +929 -0
  73. flux_engine/core/lrm.py +158 -0
  74. flux_engine/core/rng.py +736 -0
  75. flux_engine/core/seedtree.py +891 -0
  76. flux_engine/core/trace.py +423 -0
  77. flux_engine/describe.py +207 -0
  78. flux_engine/engine.py +257 -0
  79. flux_engine/evidence/__init__.py +5 -0
  80. flux_engine/evidence/pack.py +411 -0
  81. flux_engine/evidence/report.py +243 -0
  82. flux_engine/expr/__init__.py +133 -0
  83. flux_engine/expr/ast.py +312 -0
  84. flux_engine/expr/attrs.py +386 -0
  85. flux_engine/expr/check.py +278 -0
  86. flux_engine/expr/compile.py +322 -0
  87. flux_engine/expr/errors.py +256 -0
  88. flux_engine/expr/lexer.py +297 -0
  89. flux_engine/expr/parser.py +313 -0
  90. flux_engine/expr/types.py +177 -0
  91. flux_engine/generator/__init__.py +97 -0
  92. flux_engine/generator/families.py +325 -0
  93. flux_engine/generator/paths.py +227 -0
  94. flux_engine/generator/rows.py +918 -0
  95. flux_engine/generator/statistics.py +524 -0
  96. flux_engine/governance/__init__.py +87 -0
  97. flux_engine/governance/audit.py +307 -0
  98. flux_engine/governance/gate.py +334 -0
  99. flux_engine/governance/skills.py +220 -0
  100. flux_engine/governance/sovereignty.py +251 -0
  101. flux_engine/lineage/__init__.py +5 -0
  102. flux_engine/lineage/emitter.py +398 -0
  103. flux_engine/py.typed +0 -0
  104. flux_engine/runtime/__init__.py +153 -0
  105. flux_engine/runtime/campaign.py +407 -0
  106. flux_engine/runtime/cast.py +283 -0
  107. flux_engine/runtime/detector.py +222 -0
  108. flux_engine/runtime/errors.py +58 -0
  109. flux_engine/runtime/experiment.py +311 -0
  110. flux_engine/runtime/journey.py +383 -0
  111. flux_engine/runtime/jurisdiction.py +72 -0
  112. flux_engine/runtime/payloads.py +124 -0
  113. flux_engine/runtime/signals.py +334 -0
  114. flux_engine/runtime/temperature.py +156 -0
  115. flux_engine/seam/__init__.py +5 -0
  116. flux_engine/seam/attest.py +290 -0
  117. flux_engine/seam/conform.py +448 -0
  118. flux_engine/seam/contract.py +605 -0
  119. flux_engine/seam/emit.py +488 -0
  120. flux_engine/seam/mapping.py +299 -0
  121. flux_engine/seam/resolve.py +1087 -0
  122. flux_engine/seam/sinks.py +157 -0
  123. flux_engine/spec/__init__.py +134 -0
  124. flux_engine/spec/bundle.py +25 -0
  125. flux_engine/spec/enforce.py +31 -0
  126. flux_engine/spec/validate.py +28 -0
  127. flux_engine/stages/__init__.py +30 -0
  128. flux_engine/stages/apply.py +447 -0
  129. flux_engine/stages/errors.py +315 -0
  130. flux_engine/stages/layout.py +292 -0
  131. flux_engine/stages/pipeline.py +600 -0
  132. flux_engine/stages/plan.py +241 -0
  133. flux_engine/stages/replay.py +83 -0
  134. flux_engine/stages/run.py +208 -0
  135. flux_engine/stages/ship.py +934 -0
  136. flux_engine/stages/validate.py +93 -0
  137. flux_engine/stages/verify.py +1129 -0
  138. flux_engine/starter/__init__.py +161 -0
  139. flux_engine/starter/bundle/detector.flux.yml +15 -0
  140. flux_engine/starter/bundle/journey.flux.yml +23 -0
  141. flux_engine/starter/bundle/module.flux.yml +16 -0
  142. flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
  143. flux_engine/starter/bundle/persona.flux.yml +26 -0
  144. flux_engine/starter/bundle/signal.flux.yml +13 -0
  145. flux_engine/starter/bundle/simulation.flux.yml +29 -0
  146. flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
  147. flux_engine/starter/bundle/treatment.flux.yml +13 -0
  148. flux_engine/starter/bundle/world.flux.yml +20 -0
  149. flux_engine/verticals/__init__.py +85 -0
  150. flux_engine/verticals/telco.py +89 -0
  151. flux_engine/world/__init__.py +89 -0
  152. flux_engine/world/derived.py +195 -0
  153. flux_engine/world/errors.py +41 -0
  154. flux_engine/world/families.py +260 -0
  155. flux_engine/world/population.py +602 -0
  156. flux_engine/world/tracing.py +66 -0
  157. flux_engine/world/values.py +121 -0
  158. flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
  159. flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
  160. flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
  161. flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
  162. flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
  163. flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
  164. flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
  165. flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """FLUX reference policy gate (RFC-04).
3
+
4
+ The normative enforcement decision contract, implemented. A conformant gate
5
+ answers one question — *may this AI call proceed under this agentPolicy?* —
6
+ and answers it identically everywhere:
7
+
8
+ decide(policy, request, skill=None) -> decision
9
+
10
+ Checks run in a fixed, normative order and the first failure wins:
11
+
12
+ 0. INVALID_POLICY the policy itself is off-spec (fail closed)
13
+ 1. INVALID_REQUEST the request is malformed
14
+ 2. MODEL_NOT_ALLOWED model outside agentPolicy.allowedModels
15
+ 3. USE_CASE_DENIED use case listed in deniedUseCases
16
+ 4. USE_CASE_NOT_ALLOWED allowedUseCases declared and use case absent
17
+ 5. PURPOSE_REQUIRED purposeLimitation on (default true) and no purpose
18
+ 6. TOKEN_BUDGET_EXCEEDED tokens above the effective budget
19
+
20
+ Three properties make this contract portable rather than merely implementable:
21
+
22
+ **Numbers are values, not Python types.** JSON Schema `"type": "integer"`
23
+ matches `200000.0` as readily as `200000`, so a gate that tests
24
+ `isinstance(x, int)` silently stops enforcing budgets on a schema-valid
25
+ policy. Every integer here is read through `as_int()`, which accepts either
26
+ spelling and rejects booleans.
27
+
28
+ **Strings are compared after normalisation.** `useCase` is caller-supplied,
29
+ so a deny-list matched by raw equality is advisory: `Advertising`,
30
+ `advertising ` and a Cyrillic homoglyph all slip past. Identifiers are
31
+ NFC-normalised and stripped before comparison (case-sensitively — see
32
+ `normalize()`).
33
+
34
+ **Digests are canonical.** `policyDigest` is the SHA-256 of the policy
35
+ canonicalised per RFC 8785 (JCS), so a Python gate and a JavaScript gate
36
+ agree byte-for-byte. It digests the *document's* policy, never a synthesised
37
+ one; when a skill narrows the budget the decision records `skillRef` and
38
+ `effectiveBudget` separately, so an auditor can join a decision back to the
39
+ document that authorised it and still see what actually applied.
40
+
41
+ Prior art: the evaluation model is deliberately the one adopters already know
42
+ from Cedar (docs.cedarpolicy.com) and Open Policy Agent — **default deny**,
43
+ and **forbid overrides permit** (here: deniedUseCases beats allowedUseCases).
44
+ We diverge from Cedar's `{decision, diagnostics:{reason, errors}}` shape in
45
+ one respect: Cedar's `reason` lists the policy ids that matched, which is
46
+ right for a policy *language* with arbitrary rules, whereas FLUX has a fixed
47
+ check contract. A closed `reasonCode` enum is what makes conformance testable
48
+ — two gates must agree not just on the verdict but on *why*.
49
+
50
+ Usage:
51
+ python3 scripts/enforce.py # run the conformance vectors
52
+ python3 scripts/enforce.py --vectors FILE # run a specific vector file
53
+
54
+ Exit code 0 = every vector matched. Vendors demonstrate RFC-04 conformance by
55
+ running their own gate against tests/enforcement-vectors.json — which is
56
+ itself guarded by a negative test (tests/test_regression.py) proving that a
57
+ gate with known defects fails the suite.
58
+
59
+ Requires: pip install rfc8785 (pure Python, no dependencies — the Trail of
60
+ Bits implementation of RFC 8785).
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ import argparse
66
+ import hashlib
67
+ import json
68
+ import sys
69
+ import unicodedata
70
+ from pathlib import Path
71
+
72
+ try:
73
+ import rfc8785
74
+ except ImportError: # pragma: no cover
75
+ sys.exit("enforce.py requires: pip install rfc8785")
76
+
77
+ # VENDOR EDIT (see VENDOR-NOTES.md): one level shallower (see validate.py),
78
+ # and the vectors ship under vectors/ in the vendored layout, not tests/.
79
+ REPO = Path(__file__).resolve().parent
80
+ DEFAULT_VECTORS = REPO / "vectors" / "enforcement-vectors.json"
81
+
82
+ # JSON numbers are IEEE-754 doubles once they cross a language boundary, so
83
+ # integers above 2^53-1 cannot be exchanged (or canonicalised per RFC 8785)
84
+ # without silent precision loss. Values beyond it are rejected rather than
85
+ # rounded — a budget nobody can agree on is worse than no budget.
86
+ MAX_SAFE_INTEGER = 9007199254740991
87
+
88
+ REASONS = {
89
+ "ALLOWED": "the call satisfies every constraint of the policy",
90
+ "INVALID_POLICY": "agentPolicy is off-spec: {detail}",
91
+ "INVALID_REQUEST": "request is malformed: {detail}",
92
+ "MODEL_NOT_ALLOWED": "model {model!r} is not in agentPolicy.allowedModels",
93
+ "USE_CASE_DENIED": "use case {useCase!r} is listed in agentPolicy.deniedUseCases",
94
+ "USE_CASE_NOT_ALLOWED": "use case {useCase!r} is not in agentPolicy.allowedUseCases",
95
+ "PURPOSE_REQUIRED": "agentPolicy.purposeLimitation requires a purpose on every call",
96
+ "TOKEN_BUDGET_EXCEEDED": "requested {tokens} tokens, effective budget is {budget}",
97
+ }
98
+
99
+
100
+ def as_int(value):
101
+ """The JSON notion of an integer, not Python's.
102
+
103
+ JSON Schema `"type": "integer"` accepts any number with a zero fractional
104
+ part, so `200000.0` is a valid tokenBudget and a JS gate will parse it to
105
+ a float. Booleans are rejected outright — `isinstance(True, int)` is True
106
+ in Python, which would otherwise read `tokenBudget: true` as a budget of 1.
107
+ Returns None when the value is not an integer.
108
+ """
109
+ if isinstance(value, bool):
110
+ return None
111
+ if isinstance(value, int):
112
+ return value
113
+ if isinstance(value, float) and value.is_integer():
114
+ return int(value)
115
+ return None
116
+
117
+
118
+ def normalize(value: str) -> str:
119
+ """Normalise an identifier before comparison.
120
+
121
+ NFC (so a decomposed 'café' matches a composed one) plus surrounding
122
+ whitespace stripped (so 'advertising ' cannot slip past a deny-list).
123
+ Matching is case-SENSITIVE: folding case would make `Model-A` and
124
+ `model-a` the same identifier, which is wrong for model names that really
125
+ do differ. Policies should not declare identifiers differing only by case.
126
+ """
127
+ return unicodedata.normalize("NFC", value).strip()
128
+
129
+
130
+ def canonical_bytes(value) -> bytes:
131
+ """RFC 8785 (JCS) canonical serialisation — the digest's input."""
132
+ return rfc8785.dumps(value)
133
+
134
+
135
+ def policy_digest(policy) -> str:
136
+ """SHA-256 over the JCS canonicalisation of the policy.
137
+
138
+ Canonical means cross-implementation: JCS fixes key order, number
139
+ spelling (200000.0 and 200000 serialise identically) and string escaping
140
+ (raw UTF-8, not \\uXXXX), so a Python gate and a JavaScript gate produce
141
+ the same digest for the same policy.
142
+ """
143
+ return "sha256:" + hashlib.sha256(canonical_bytes(policy)).hexdigest()
144
+
145
+
146
+ def _string_list(value):
147
+ return isinstance(value, list) and value and all(isinstance(v, str) and v.strip() for v in value)
148
+
149
+
150
+ def policy_problem(policy) -> str | None:
151
+ """Why this policy is off-spec, or None. Mirrors $defs/agentPolicy.
152
+
153
+ A gate MUST reject an off-spec policy rather than enforce a degraded
154
+ reading of it: a missing or non-integer tokenBudget must never mean
155
+ "no budget".
156
+ """
157
+ if not isinstance(policy, dict):
158
+ return "policy must be an object"
159
+ if not _string_list(policy.get("allowedModels")):
160
+ return "allowedModels must be a non-empty array of non-empty strings"
161
+ budget = as_int(policy.get("tokenBudget"))
162
+ if budget is None or budget < 1:
163
+ return "tokenBudget must be an integer of at least 1"
164
+ if budget > MAX_SAFE_INTEGER:
165
+ return f"tokenBudget exceeds the JSON-safe integer domain ({MAX_SAFE_INTEGER})"
166
+ for field in ("allowedUseCases", "deniedUseCases"):
167
+ if field in policy and not _string_list(policy[field]):
168
+ return f"{field}, when present, must be a non-empty array of non-empty strings"
169
+ if "purposeLimitation" in policy and not isinstance(policy["purposeLimitation"], bool):
170
+ return "purposeLimitation must be a boolean"
171
+ return None
172
+
173
+
174
+ def request_problem(request) -> str | None:
175
+ if not isinstance(request, dict):
176
+ return "request must be an object"
177
+ for field in ("model", "useCase"):
178
+ value = request.get(field)
179
+ if not isinstance(value, str) or not value.strip():
180
+ return f"{field} must be a non-empty string"
181
+ tokens = as_int(request.get("tokens"))
182
+ if tokens is None:
183
+ return "tokens must be an integer"
184
+ if tokens < 0:
185
+ return "tokens must not be negative"
186
+ if tokens > MAX_SAFE_INTEGER:
187
+ return f"tokens exceeds the JSON-safe integer domain ({MAX_SAFE_INTEGER})"
188
+ if "purpose" in request and not isinstance(request["purpose"], str):
189
+ return "purpose, when present, must be a string"
190
+ return None
191
+
192
+
193
+ def effective_budget(policy, skill=None) -> int:
194
+ """The budget that actually applies to this call.
195
+
196
+ A skill NARROWS: the effective budget is min(skill, policy). It can never
197
+ widen one — the document validator rejects a skill budget above its
198
+ policy's, and this is the belt to that braces.
199
+ """
200
+ budget = as_int(policy.get("tokenBudget"))
201
+ skill_budget = as_int((skill or {}).get("tokenBudget"))
202
+ if skill_budget is None:
203
+ return budget
204
+ return min(skill_budget, budget)
205
+
206
+
207
+ def effective_policy(policy: dict, skill: dict | None = None) -> dict:
208
+ """Back-compat helper: the policy with its budget narrowed by `skill`.
209
+
210
+ Prefer passing `skill` to decide() directly — that keeps policyDigest
211
+ bound to the document's own policy instead of a synthesised one.
212
+ """
213
+ if not skill:
214
+ return policy
215
+ narrowed = dict(policy)
216
+ budget = effective_budget(policy, skill)
217
+ if budget is not None:
218
+ narrowed["tokenBudget"] = budget
219
+ return narrowed
220
+
221
+
222
+ def _decision(allow, code, policy, request, skill=None, budget=None, **fmt):
223
+ fields = {**(request if isinstance(request, dict) else {}), "budget": budget, **fmt}
224
+ decision = {
225
+ "allow": allow,
226
+ "reasonCode": code,
227
+ "reason": REASONS[code].format(**fields),
228
+ "policyDigest": policy_digest(policy) if isinstance(policy, dict) else policy_digest({}),
229
+ "request": request if isinstance(request, dict) else {},
230
+ }
231
+ if budget is not None:
232
+ decision["effectiveBudget"] = budget
233
+ if skill and isinstance(skill.get("name"), str):
234
+ decision["skillRef"] = skill["name"]
235
+ return decision
236
+
237
+
238
+ def decide(policy: dict, request: dict, skill: dict | None = None) -> dict:
239
+ """Evaluate one call against one agentPolicy. Pure and deterministic.
240
+
241
+ `policy` is the document's agentPolicy — pass `skill` rather than a
242
+ pre-narrowed policy, so the decision's digest identifies the authorising
243
+ document and `effectiveBudget`/`skillRef` record what actually applied.
244
+ """
245
+ problem = policy_problem(policy)
246
+ if problem:
247
+ return _decision(False, "INVALID_POLICY", policy, request, skill, detail=problem)
248
+
249
+ problem = request_problem(request)
250
+ if problem:
251
+ return _decision(False, "INVALID_REQUEST", policy, request, skill, detail=problem)
252
+
253
+ budget = effective_budget(policy, skill)
254
+ model = normalize(request["model"])
255
+ use_case = normalize(request["useCase"])
256
+
257
+ if model not in {normalize(m) for m in policy["allowedModels"]}:
258
+ return _decision(False, "MODEL_NOT_ALLOWED", policy, request, skill, budget=budget)
259
+
260
+ if use_case in {normalize(u) for u in policy.get("deniedUseCases") or []}:
261
+ return _decision(False, "USE_CASE_DENIED", policy, request, skill, budget=budget)
262
+
263
+ if "allowedUseCases" in policy and use_case not in {normalize(u) for u in policy["allowedUseCases"]}:
264
+ return _decision(False, "USE_CASE_NOT_ALLOWED", policy, request, skill, budget=budget)
265
+
266
+ if policy.get("purposeLimitation", True) and not (request.get("purpose") or "").strip():
267
+ return _decision(False, "PURPOSE_REQUIRED", policy, request, skill, budget=budget)
268
+
269
+ if as_int(request["tokens"]) > budget:
270
+ return _decision(False, "TOKEN_BUDGET_EXCEEDED", policy, request, skill, budget=budget)
271
+
272
+ return _decision(True, "ALLOWED", policy, request, skill, budget=budget)
273
+
274
+
275
+ def run_vectors(path: Path, gate=decide, label: str = "reference gate") -> tuple[int, list[str]]:
276
+ """Run a vector file against `gate`. Returns (failures, messages).
277
+
278
+ Every vector asserts allow, reasonCode, and — where the vector pins one —
279
+ policyDigest, so a gate cannot pass by guessing verdicts while computing
280
+ digests wrongly (or not at all).
281
+ """
282
+ vectors = json.loads(path.read_text())
283
+ messages = []
284
+ for v in vectors["vectors"]:
285
+ decision = gate(v["policy"], v["request"], v.get("skill"))
286
+ expect = v["expect"]
287
+ if decision.get("allow") != expect["allow"] or decision.get("reasonCode") != expect["reasonCode"]:
288
+ messages.append(
289
+ f"{v['name']}: expected {expect['reasonCode']} (allow={expect['allow']}), "
290
+ f"got {decision.get('reasonCode')} (allow={decision.get('allow')})"
291
+ )
292
+ continue
293
+ if "policyDigest" in expect and decision.get("policyDigest") != expect["policyDigest"]:
294
+ messages.append(f"{v['name']}: policyDigest mismatch — expected {expect['policyDigest']}, got {decision.get('policyDigest')}")
295
+ if "effectiveBudget" in expect and decision.get("effectiveBudget") != expect["effectiveBudget"]:
296
+ messages.append(f"{v['name']}: effectiveBudget mismatch — expected {expect['effectiveBudget']}, got {decision.get('effectiveBudget')}")
297
+ return len(messages), messages
298
+
299
+
300
+ def main(argv=None) -> int:
301
+ ap = argparse.ArgumentParser(description="FLUX reference policy gate (RFC-04)")
302
+ ap.add_argument("--vectors", type=Path, default=DEFAULT_VECTORS)
303
+ args = ap.parse_args(argv)
304
+ total = len(json.loads(args.vectors.read_text())["vectors"])
305
+ failures, messages = run_vectors(args.vectors)
306
+ if failures:
307
+ print(f"FAIL — {failures}/{total} enforcement vectors failed")
308
+ for m in messages:
309
+ print(" ", m)
310
+ return 1
311
+ print(f"ok — {total} enforcement vectors green")
312
+ return 0
313
+
314
+
315
+ if __name__ == "__main__":
316
+ raise SystemExit(main())
@@ -0,0 +1,121 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agenticstiger.github.io/flux/schema/flux-enforcement-0.5.0.json",
4
+ "title": "FLUX Enforcement Decision Contract",
5
+ "description": "RFC-04: the normative runtime contract every conformant policy gate implements. Input: an agentPolicy (from any FLUX document), a request, and optionally the skill binding the call is made through. Output: a decision. Checks run in a fixed, normative order \u2014 INVALID_POLICY, INVALID_REQUEST, MODEL_NOT_ALLOWED, USE_CASE_DENIED, USE_CASE_NOT_ALLOWED, PURPOSE_REQUIRED, TOKEN_BUDGET_EXCEEDED \u2014 and the first failure wins, so identical inputs yield identical decisions on every implementation. NUMBERS: integers follow JSON semantics, not a host language's types \u2014 200000.0 is the integer 200000, and booleans are never integers. Values above 2^53-1 (9007199254740991) are rejected, because they cannot cross a JSON boundary or be canonicalised per RFC 8785 without silent precision loss. STRINGS: model and useCase identifiers are NFC-normalised and surrounding whitespace stripped before comparison; matching is case-sensitive. POLICIES: a gate MUST reject an off-spec policy with INVALID_POLICY rather than enforce a degraded reading of it \u2014 a missing or non-integer tokenBudget is never an unlimited budget. SKILLS: when a call is made through a skill, the effective budget is min(skill.tokenBudget, policy.tokenBudget) \u2014 a skill narrows and can never widen. The decision reports it as effectiveBudget, and policyDigest always digests the DOCUMENT's policy so an auditor can join a decision back to the document that authorised it. DIGESTS: policyDigest is sha256 over the RFC 8785 (JCS) canonicalisation of the policy, so gates in different languages agree byte-for-byte. Conformance is demonstrated by passing the published test vectors (tests/enforcement-vectors.json), which pin allow, reasonCode and policyDigest.",
6
+ "$defs": {
7
+ "request": {
8
+ "type": "object",
9
+ "required": [
10
+ "model",
11
+ "useCase",
12
+ "tokens"
13
+ ],
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "model": {
17
+ "type": "string",
18
+ "minLength": 1,
19
+ "description": "Model identifier the caller wants to invoke."
20
+ },
21
+ "useCase": {
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "description": "Declared use case of the call."
25
+ },
26
+ "purpose": {
27
+ "type": "string",
28
+ "minLength": 1,
29
+ "description": "Free-text purpose statement. Required by policies with purposeLimitation (the default)."
30
+ },
31
+ "tokens": {
32
+ "type": "integer",
33
+ "minimum": 0,
34
+ "description": "Requested token budget for the call. An integer in the JSON sense (200000.0 is 200000; true is not 1)."
35
+ }
36
+ }
37
+ },
38
+ "decision": {
39
+ "type": "object",
40
+ "required": [
41
+ "allow",
42
+ "reasonCode",
43
+ "reason",
44
+ "policyDigest",
45
+ "request"
46
+ ],
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "allow": {
50
+ "type": "boolean"
51
+ },
52
+ "reasonCode": {
53
+ "type": "string",
54
+ "enum": [
55
+ "ALLOWED",
56
+ "INVALID_POLICY",
57
+ "INVALID_REQUEST",
58
+ "MODEL_NOT_ALLOWED",
59
+ "USE_CASE_DENIED",
60
+ "USE_CASE_NOT_ALLOWED",
61
+ "PURPOSE_REQUIRED",
62
+ "TOKEN_BUDGET_EXCEEDED"
63
+ ],
64
+ "description": "Machine-readable outcome. Deny codes follow the normative check order; the first failing check wins."
65
+ },
66
+ "reason": {
67
+ "type": "string",
68
+ "minLength": 1,
69
+ "description": "Human-readable explanation."
70
+ },
71
+ "policyDigest": {
72
+ "type": "string",
73
+ "pattern": "^sha256:[a-f0-9]{64}$",
74
+ "description": "sha256 over the RFC 8785 (JCS) canonicalisation of the DOCUMENT's agentPolicy \u2014 the audit anchor binding decision to policy. Never a synthesised or skill-narrowed policy."
75
+ },
76
+ "request": {
77
+ "type": "object",
78
+ "description": "Echo of the evaluated request. Deliberately unconstrained: on the INVALID_REQUEST path the request is malformed by definition, and the decision must still be able to carry it. The decision plus this echo is the audit record."
79
+ },
80
+ "effectiveBudget": {
81
+ "type": "integer",
82
+ "minimum": 0,
83
+ "description": "The token budget actually applied \u2014 min(skill, policy) when a skill narrows it. Present whenever the policy was well-formed."
84
+ },
85
+ "skillRef": {
86
+ "type": "string",
87
+ "minLength": 1,
88
+ "description": "Name of the skill binding the call was made through, when there was one. With effectiveBudget this makes the narrowing auditable without mutating the policy."
89
+ }
90
+ },
91
+ "allOf": [
92
+ {
93
+ "if": {
94
+ "properties": {
95
+ "reasonCode": {
96
+ "const": "ALLOWED"
97
+ }
98
+ },
99
+ "required": [
100
+ "reasonCode"
101
+ ]
102
+ },
103
+ "then": {
104
+ "properties": {
105
+ "allow": {
106
+ "const": true
107
+ }
108
+ }
109
+ },
110
+ "else": {
111
+ "properties": {
112
+ "allow": {
113
+ "const": false
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ]
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,153 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agenticstiger.github.io/flux/schema/flux-manifest-0.5.0.json",
4
+ "title": "FLUX Bundle Manifest",
5
+ "description": "RFC-09: the self-describing manifest inside a FLUX evidence bundle. The archive is deterministic (sorted entries, zeroed timestamps), so identical bundle directories produce byte-identical archives. Signing is a detached signature over the manifest file bytes (flux-manifest.json), transport-agnostic: ssh-keygen -Y, cosign, or openssl all work. A verifier recomputes every file digest and the Merkle root offline, then replays the reference validator.",
6
+ "type": "object",
7
+ "required": [
8
+ "manifestVersion",
9
+ "schemaId",
10
+ "fluxVersions",
11
+ "files",
12
+ "merkleRoot",
13
+ "attestation"
14
+ ],
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "manifestVersion": {
18
+ "const": 1
19
+ },
20
+ "generator": {
21
+ "type": "string"
22
+ },
23
+ "schemaId": {
24
+ "type": "string",
25
+ "pattern": "^https://",
26
+ "description": "The $id of the FLUX schema the bundle was validated against."
27
+ },
28
+ "fluxVersions": {
29
+ "type": "array",
30
+ "minItems": 1,
31
+ "uniqueItems": true,
32
+ "items": {
33
+ "type": "string",
34
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
35
+ },
36
+ "description": "Every fluxVersion pinned by documents in the bundle."
37
+ },
38
+ "files": {
39
+ "type": "object",
40
+ "minProperties": 1,
41
+ "additionalProperties": {
42
+ "type": "string",
43
+ "pattern": "^sha256:[a-f0-9]{64}$"
44
+ },
45
+ "description": "Relative path -> sha256 of every file in the bundle (the manifest itself excluded)."
46
+ },
47
+ "merkleRoot": {
48
+ "type": "string",
49
+ "pattern": "^sha256:[a-f0-9]{64}$",
50
+ "description": "Root over the sorted file entries. Leaves are sha256('leaf:' + path + ':' + fileDigestHex) in path order; parents are sha256('node:' + leftHex + rightHex); an odd node is promoted unchanged."
51
+ },
52
+ "attestation": {
53
+ "type": "object",
54
+ "required": [
55
+ "profile",
56
+ "validator"
57
+ ],
58
+ "additionalProperties": false,
59
+ "properties": {
60
+ "profile": {
61
+ "type": "string",
62
+ "enum": [
63
+ "core",
64
+ "enterprise"
65
+ ],
66
+ "description": "Highest conformance profile the offline toolchain can attest. 'enterprise' requires a green validator plus a Playback scorecard in the bundle; 'runtime' (Level 3) requires a live engine and is never claimable by an offline bundler."
67
+ },
68
+ "validator": {
69
+ "type": "object",
70
+ "required": [
71
+ "status"
72
+ ],
73
+ "additionalProperties": false,
74
+ "properties": {
75
+ "status": {
76
+ "const": "green"
77
+ },
78
+ "documents": {
79
+ "type": "integer",
80
+ "minimum": 1
81
+ }
82
+ }
83
+ },
84
+ "scorecards": {
85
+ "type": "array",
86
+ "minItems": 1,
87
+ "items": {
88
+ "type": "object",
89
+ "required": [
90
+ "playbackId",
91
+ "grade",
92
+ "credibility"
93
+ ],
94
+ "additionalProperties": false,
95
+ "properties": {
96
+ "playbackId": {
97
+ "type": "string"
98
+ },
99
+ "grade": {
100
+ "type": "string",
101
+ "enum": [
102
+ "A",
103
+ "B",
104
+ "C",
105
+ "D",
106
+ "E",
107
+ "F"
108
+ ]
109
+ },
110
+ "credibility": {
111
+ "type": "number",
112
+ "minimum": 0,
113
+ "maximum": 1
114
+ }
115
+ }
116
+ }
117
+ },
118
+ "seam": {
119
+ "type": "array",
120
+ "minItems": 1,
121
+ "items": {
122
+ "type": "object",
123
+ "required": [
124
+ "simulationId",
125
+ "productRef",
126
+ "exposeId",
127
+ "fluidVersion"
128
+ ],
129
+ "additionalProperties": false,
130
+ "properties": {
131
+ "simulationId": {
132
+ "type": "string"
133
+ },
134
+ "productRef": {
135
+ "type": "string"
136
+ },
137
+ "exposeId": {
138
+ "type": "string"
139
+ },
140
+ "fluidVersion": {
141
+ "type": "string"
142
+ },
143
+ "contractDigest": {
144
+ "type": "string",
145
+ "pattern": "^sha256:[a-f0-9]{64}$"
146
+ }
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }