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,554 @@
1
+ # Copyright 2026 Agentics Transformation Limited
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """The fidelity record -- SEAM-FIDELITY-SPEC §6.
6
+
7
+ One record per verified emit, in the evidence pack under
8
+ ``run/fidelity-record-<exposeId>.json``. §6 fixes the shape; this module
9
+ builds it, signs it, and reads it back.
10
+
11
+ The scope statement is generated from a fixed template, never
12
+ hand-edited; its ``doesNotCertify`` list is the register-discipline
13
+ guard. The record is signed by the verifier key (ed25519 via
14
+ ``cryptography``) [...].
15
+
16
+ Three choices worth stating.
17
+
18
+ **The scope statement is a constant, not a parameter.** :data:`SCOPE` is
19
+ built from :data:`SCOPE_CERTIFIES` and :data:`SCOPE_DOES_NOT_CERTIFY`,
20
+ which no function takes an argument for and no caller can override.
21
+ :func:`load_record` refuses a record whose scope statement is not that
22
+ one, so a record that quietly narrowed its own limits is unreadable rather
23
+ than persuasive.
24
+
25
+ **The signature covers everything except itself -- and that includes who
26
+ signed.** The signed bytes are the canonical JSON of the record with the
27
+ ``signature`` field removed, under a domain tag, so a field added to the
28
+ record after signing invalidates the signature and the signed bytes
29
+ cannot be replayed as the preimage of anything else. The signer's own
30
+ identity -- key id, public key, whether the key was a per-invocation one
31
+ -- therefore lives in a top-level ``signer`` block (:func:`signer_block`)
32
+ *inside* the signed body, not in the excluded ``signature`` object. With
33
+ it outside, anyone could rewrite a record's body, sign it with a key of
34
+ their own, drop that key's public half in beside the signature, and
35
+ produce a document that verified against the only key travelling with it.
36
+
37
+ **A verifier checks against keys its caller trusts.** :func:`verify_record`
38
+ takes the trust anchors as an argument and reports a record that verifies
39
+ under none of them as unverified. It never takes the key out of the
40
+ record: a key a document carries about itself answers "is this document
41
+ internally consistent", not "who signed it".
42
+
43
+ **An unsigned record says it is unsigned.** ``cryptography`` is a declared
44
+ dependency, so :class:`Ed25519Signer` is the ordinary path. The fallback
45
+ exists for an environment without it, and it writes
46
+ ``"alg": "unsigned-fallback"`` and an empty signature rather than omitting
47
+ the field: a reader must be able to tell an unsigned record from a signed
48
+ one at a glance, and an absent field would not tell them.
49
+
50
+ Nothing here reads a clock. The commitment time comes from the committed
51
+ state and the record carries no generation timestamp of its own -- the
52
+ evidence pack's envelope (``flux_engine.core.artifact``) is where a
53
+ wall-clock field belongs, and it is masked out of every digest.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import base64
59
+ from dataclasses import dataclass
60
+ from types import MappingProxyType
61
+ from typing import (
62
+ Any,
63
+ Dict,
64
+ List,
65
+ Mapping,
66
+ Optional,
67
+ Protocol,
68
+ Sequence,
69
+ Tuple,
70
+ Union,
71
+ runtime_checkable,
72
+ )
73
+
74
+ from flux_engine.concordance.boundary.slice import CommittedState
75
+ from flux_engine.concordance.compiler import CompiledFidelity
76
+ from flux_engine.concordance.errors import FLX_8002
77
+ from flux_engine.concordance.verifier import FidelityEvaluation
78
+ from flux_engine.core.canonical import canonical_bytes
79
+ from flux_engine.core.errors import FluxError
80
+
81
+ __all__ = [
82
+ "Ed25519Signer",
83
+ "FidelityRecord",
84
+ "RECORD_VERSION",
85
+ "SCOPE",
86
+ "SCOPE_CERTIFIES",
87
+ "SCOPE_DOES_NOT_CERTIFY",
88
+ "SIGNATURE_PREFIX",
89
+ "TrustedKeys",
90
+ "RecordSigner",
91
+ "SyntheticReference",
92
+ "UnsignedFallbackSigner",
93
+ "build_record",
94
+ "load_record",
95
+ "record_signer_public_key",
96
+ "signer_block",
97
+ "signing_payload",
98
+ "verify_record",
99
+ ]
100
+
101
+ #: What :func:`verify_record` accepts as a trust anchor: one raw ed25519
102
+ #: public key, several of them, or ``None`` for "the caller has none" --
103
+ #: which is a reason a record is unverified, never a reason to fall back
104
+ #: to the key the record names about itself.
105
+ TrustedKeys = Union[bytes, Sequence[bytes], None]
106
+
107
+ #: §6's ``recordVersion``.
108
+ RECORD_VERSION = 1
109
+
110
+ #: Domain tag on the signed bytes, so the preimage of a record signature
111
+ #: cannot be the preimage of anything else this engine signs.
112
+ SIGNATURE_PREFIX = b"flux-fidelity-record/1\x00"
113
+
114
+ #: §6's scope statement, verbatim. Not a template with slots: the whole
115
+ #: point of the field is that it says the same thing on every record.
116
+ SCOPE_CERTIFIES = (
117
+ "statistical relation between the synthetic dataset and the committed state "
118
+ "under the registered properties"
119
+ )
120
+
121
+ #: §6's ``doesNotCertify`` list, verbatim and in order. It is the
122
+ #: register-discipline guard: every limit a reader might otherwise assume
123
+ #: away is written down where they will read it.
124
+ SCOPE_DOES_NOT_CERTIFY: Tuple[str, ...] = (
125
+ "privacy or re-identification resistance of the synthetic dataset",
126
+ "fairness or any property of the source dataset itself",
127
+ "any property of a system trained on or tested with the synthetic dataset",
128
+ "that the synthetic dataset was generated independently of the source",
129
+ "that the committed state is the whole source dataset",
130
+ "predictive validity of any simulation outcome",
131
+ )
132
+
133
+ #: The generated scope statement, as it appears in every record.
134
+ SCOPE: Mapping[str, Any] = MappingProxyType(
135
+ {
136
+ "certifies": SCOPE_CERTIFIES,
137
+ "doesNotCertify": list(SCOPE_DOES_NOT_CERTIFY),
138
+ }
139
+ )
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Signing
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ @runtime_checkable
148
+ class RecordSigner(Protocol):
149
+ """The signature port. One implementation signs; one states it did not."""
150
+
151
+ @property
152
+ def alg(self) -> str:
153
+ """The signature algorithm's wire name, written into the record."""
154
+
155
+ @property
156
+ def key_id(self) -> str:
157
+ """The id of the key the signature was taken with."""
158
+
159
+ def sign(self, payload: bytes) -> str:
160
+ """Return the base64 signature over *payload*."""
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class Ed25519Signer:
165
+ """§6's signer: ed25519 over the record's canonical bytes.
166
+
167
+ Takes the 32-byte raw private key rather than a key object so the
168
+ caller decides where key material comes from and this module never
169
+ touches a file, an environment variable or a key store.
170
+ """
171
+
172
+ private_key: bytes
173
+ key_id: str
174
+ alg: str = "ed25519"
175
+
176
+ def __post_init__(self) -> None:
177
+ if len(self.private_key) != 32:
178
+ raise FluxError(FLX_8002, "an ed25519 private key is 32 raw bytes")
179
+ if not self.key_id.strip():
180
+ raise FluxError(FLX_8002, "a signature names the key it was taken with")
181
+
182
+ def sign(self, payload: bytes) -> str:
183
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
184
+
185
+ key = Ed25519PrivateKey.from_private_bytes(self.private_key)
186
+ return base64.b64encode(key.sign(payload)).decode("ascii")
187
+
188
+ @property
189
+ def public_key(self) -> bytes:
190
+ """The 32-byte raw public key a verifier checks the signature with."""
191
+ from cryptography.hazmat.primitives import serialization
192
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
193
+
194
+ key = Ed25519PrivateKey.from_private_bytes(self.private_key)
195
+ return key.public_key().public_bytes(
196
+ encoding=serialization.Encoding.Raw,
197
+ format=serialization.PublicFormat.Raw,
198
+ )
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class UnsignedFallbackSigner:
203
+ """States that a record was not signed, rather than omitting the field.
204
+
205
+ Used where ``cryptography`` is unavailable. :func:`verify_record`
206
+ never accepts a record carrying this algorithm as verified -- it
207
+ reports it as unsigned -- so the fallback cannot be mistaken for a
208
+ weaker signature. It is a stated absence.
209
+ """
210
+
211
+ key_id: str = "unsigned"
212
+ alg: str = "unsigned-fallback"
213
+
214
+ def sign(self, payload: bytes) -> str:
215
+ return ""
216
+
217
+
218
+ def signer_block(signer: RecordSigner, *, ephemeral: bool) -> Dict[str, Any]:
219
+ """The record's top-level ``signer`` block: who took the signature.
220
+
221
+ Written into the document **before** it is signed, so it is covered by
222
+ the signature. ``publicKey`` is the signer's own 32-byte raw key as
223
+ hex when it has one (``None`` for the unsigned fallback), and
224
+ ``ephemeral`` says whether that key was generated for one invocation
225
+ and belongs to nobody -- the difference between "this record is
226
+ attributable" and "these bytes are internally consistent".
227
+ """
228
+ public_key = getattr(signer, "public_key", None)
229
+ return {
230
+ "alg": signer.alg,
231
+ "keyId": signer.key_id,
232
+ "publicKey": None if public_key is None else bytes(public_key).hex(),
233
+ "ephemeral": bool(ephemeral),
234
+ }
235
+
236
+
237
+ def record_signer_public_key(document: Mapping[str, Any]) -> Optional[bytes]:
238
+ """The raw public key the record's ``signer`` block names, or ``None``.
239
+
240
+ A convenience for a reader that wants to check a record's internal
241
+ consistency. It is **not** a trust anchor: a record naming its own key
242
+ verifies against itself no matter who wrote it, which is exactly what
243
+ :func:`verify_record` refuses to accept as an answer.
244
+ """
245
+ signer = document.get("signer")
246
+ if not isinstance(signer, Mapping):
247
+ return None
248
+ declared = signer.get("publicKey")
249
+ if not isinstance(declared, str):
250
+ return None
251
+ try:
252
+ return bytes.fromhex(declared)
253
+ except ValueError:
254
+ return None
255
+
256
+
257
+ def signing_payload(document: Mapping[str, Any]) -> bytes:
258
+ """The bytes a record's signature is taken over.
259
+
260
+ The record's canonical JSON with ``signature`` removed, under
261
+ :data:`SIGNATURE_PREFIX`. Removing the field rather than blanking it
262
+ keeps the payload identical whether the record has been signed yet or
263
+ not, so signing and verifying hash the same bytes.
264
+ """
265
+ body = {key: value for key, value in document.items() if key != "signature"}
266
+ return SIGNATURE_PREFIX + canonical_bytes(body)
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # The record
271
+ # ---------------------------------------------------------------------------
272
+
273
+
274
+ @dataclass(frozen=True)
275
+ class SyntheticReference:
276
+ """§6's ``synthetic`` block: what was checked, and what produced it."""
277
+
278
+ output_digest: str
279
+ rows: int
280
+ generator_digest: str
281
+
282
+ def to_document(self) -> Dict[str, Any]:
283
+ """The canonical form."""
284
+ return {
285
+ "outputDigest": self.output_digest,
286
+ "rows": self.rows,
287
+ "generatorDigest": self.generator_digest,
288
+ }
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class FidelityRecord:
293
+ """One fidelity record: §6's document, and the signer that closed it."""
294
+
295
+ document: Mapping[str, Any]
296
+
297
+ @property
298
+ def h(self) -> str:
299
+ """The seam digest the record is bound to."""
300
+ return str(self.document["H"])
301
+
302
+ @property
303
+ def g(self) -> str:
304
+ """The digest of the declarations the committed statistic was formed under."""
305
+ return str(self.document["G"])
306
+
307
+ @property
308
+ def expose_id(self) -> str:
309
+ """The emit this record answers for."""
310
+ return str(self.document["reference"]["exposeId"])
311
+
312
+ @property
313
+ def signed(self) -> bool:
314
+ """Whether the record carries a real signature rather than a stated absence."""
315
+ signature = self.document.get("signature", {})
316
+ return bool(signature.get("sig")) and signature.get("alg") != UnsignedFallbackSigner.alg
317
+
318
+ @property
319
+ def outcomes(self) -> Tuple[str, ...]:
320
+ """Each declared property's outcome, in declaration order."""
321
+ return tuple(str(row["outcome"]) for row in self.document["properties"])
322
+
323
+ @property
324
+ def within(self) -> bool:
325
+ """Whether every property this record reports is within its margin."""
326
+ return all(outcome == "within" for outcome in self.outcomes)
327
+
328
+ def to_document(self) -> Dict[str, Any]:
329
+ """A mutable copy of the record document."""
330
+ return dict(self.document)
331
+
332
+
333
+ def build_record(
334
+ *,
335
+ compiled: CompiledFidelity,
336
+ committed: CommittedState,
337
+ evaluation: FidelityEvaluation,
338
+ synthetic: SyntheticReference,
339
+ product_ref: str,
340
+ fluid_version: str,
341
+ contract_digest: str,
342
+ check_ordinal: int,
343
+ signer: RecordSigner,
344
+ ephemeral_key: bool = False,
345
+ proof_system: str = "none",
346
+ record_range: Optional[Sequence[int]] = None,
347
+ ) -> FidelityRecord:
348
+ """Build and sign one fidelity record (§6).
349
+
350
+ Refuses (``FLX-8002``) to write a record for an evaluation that met a
351
+ fail-closed condition: §5 says the check declines in those cases, and
352
+ a record is the artefact that says a check *ran*. An evaluation whose
353
+ properties are outside their margins does get a record -- that is a
354
+ result, and the gate is what withholds the bundle over it.
355
+
356
+ ``recordCount`` and ``recordRange`` are §6's alternatives. The count
357
+ is written by default; pass *record_range* (a two-element ``[min,
358
+ max]``) when the owner would rather publish the declared range than
359
+ the exact number, which §4 allows.
360
+ """
361
+ if evaluation.refused:
362
+ raise FluxError(
363
+ FLX_8002,
364
+ f"the fidelity check met {len(evaluation.refusals)} fail-closed conditions; "
365
+ "there is no record to write",
366
+ )
367
+ if evaluation.expose_id != compiled.block.expose_id:
368
+ raise FluxError(FLX_8002, "the evaluation answers for a different expose")
369
+ if check_ordinal < 1:
370
+ raise FluxError(FLX_8002, "a checkOrdinal counts from one")
371
+
372
+ committed_block: Dict[str, Any] = {
373
+ "ordinal": committed.ordinal,
374
+ "C1": [entry.to_document() for entry in committed.c1],
375
+ "C2": committed.c2,
376
+ "committedAt": committed.committed_at,
377
+ "checkOrdinal": check_ordinal,
378
+ }
379
+ if record_range is None:
380
+ committed_block["recordCount"] = committed.record_count
381
+ else:
382
+ bounds = list(record_range)
383
+ if len(bounds) != 2 or bounds[0] > bounds[1]:
384
+ raise FluxError(FLX_8002, "a recordRange is an ascending pair")
385
+ committed_block["recordRange"] = bounds
386
+
387
+ document: Dict[str, Any] = {
388
+ "recordVersion": RECORD_VERSION,
389
+ "reference": {
390
+ "productRef": product_ref,
391
+ "exposeId": compiled.block.expose_id,
392
+ "fluidVersion": fluid_version,
393
+ "contractDigest": contract_digest,
394
+ },
395
+ "H": compiled.h.value,
396
+ "G": compiled.g.value,
397
+ "committedState": committed_block,
398
+ "synthetic": synthetic.to_document(),
399
+ "properties": [outcome.to_record_row() for outcome in evaluation.outcomes],
400
+ "proofSystem": proof_system,
401
+ "scope": {
402
+ "certifies": SCOPE_CERTIFIES,
403
+ "doesNotCertify": list(SCOPE_DOES_NOT_CERTIFY),
404
+ },
405
+ "columnsCovered": list(compiled.block.columns_covered),
406
+ # Inside the signed body, never beside the signature: see this
407
+ # module's docstring. *ephemeral_key* is the caller's statement
408
+ # about where the key material came from -- this module never
409
+ # generates one, so it cannot work that out for itself.
410
+ "signer": signer_block(signer, ephemeral=ephemeral_key),
411
+ }
412
+ document["signature"] = {
413
+ "alg": signer.alg,
414
+ "keyId": signer.key_id,
415
+ "sig": signer.sign(signing_payload(document)),
416
+ }
417
+ return FidelityRecord(document=MappingProxyType(document))
418
+
419
+
420
+ #: Every key §6 fixes at the top level of a record.
421
+ _REQUIRED_KEYS = (
422
+ "H",
423
+ "G",
424
+ "columnsCovered",
425
+ "committedState",
426
+ "properties",
427
+ "proofSystem",
428
+ "recordVersion",
429
+ "reference",
430
+ "scope",
431
+ "signature",
432
+ "signer",
433
+ "synthetic",
434
+ )
435
+
436
+
437
+ def load_record(document: Mapping[str, Any]) -> FidelityRecord:
438
+ """Read a record back, refusing (``FLX-8002``) one that is not §6's shape.
439
+
440
+ Checks the version, the top-level keys, and -- the one check that is
441
+ not merely structural -- that the scope statement is the generated
442
+ one. A record whose ``doesNotCertify`` list has been shortened is not
443
+ a record with a smaller list; it is a different document wearing this
444
+ one's name.
445
+ """
446
+ if not isinstance(document, Mapping):
447
+ raise FluxError(FLX_8002, f"a record is a mapping, got {type(document).__name__}")
448
+ missing = [key for key in _REQUIRED_KEYS if key not in document]
449
+ if missing:
450
+ raise FluxError(FLX_8002, f"the record is missing {', '.join(missing)}")
451
+ if document["recordVersion"] != RECORD_VERSION:
452
+ raise FluxError(
453
+ FLX_8002,
454
+ f"recordVersion {document['recordVersion']!r} is not {RECORD_VERSION}",
455
+ )
456
+ scope = document["scope"]
457
+ if not isinstance(scope, Mapping):
458
+ raise FluxError(FLX_8002, "the record's scope statement is not a mapping")
459
+ if scope.get("certifies") != SCOPE_CERTIFIES:
460
+ raise FluxError(FLX_8002, "the record's scope statement is not the generated one")
461
+ if tuple(scope.get("doesNotCertify", ())) != SCOPE_DOES_NOT_CERTIFY:
462
+ raise FluxError(
463
+ FLX_8002,
464
+ "the record's scope statement has a different list of what it does not cover",
465
+ )
466
+ properties = document["properties"]
467
+ if not isinstance(properties, Sequence) or isinstance(properties, (str, bytes)):
468
+ raise FluxError(FLX_8002, "the record's properties are not a list")
469
+ for index, row in enumerate(properties):
470
+ if not isinstance(row, Mapping) or row.get("outcome") not in ("within", "outside"):
471
+ raise FluxError(FLX_8002, f"the record's property row {index} has no outcome")
472
+ return FidelityRecord(document=MappingProxyType(dict(document)))
473
+
474
+
475
+ def _trust_anchors(trusted_keys: TrustedKeys) -> Tuple[Tuple[bytes, ...], List[str]]:
476
+ """Normalise *trusted_keys* to raw 32-byte keys, plus the reasons any were refused."""
477
+ if trusted_keys is None:
478
+ return (), []
479
+ candidates: Sequence[bytes]
480
+ if isinstance(trusted_keys, (bytes, bytearray)):
481
+ candidates = (bytes(trusted_keys),)
482
+ else:
483
+ candidates = [bytes(key) for key in trusted_keys]
484
+ anchors = tuple(key for key in candidates if len(key) == 32)
485
+ reasons = ["an ed25519 public key is 32 raw bytes"] if len(anchors) != len(candidates) else []
486
+ return anchors, reasons
487
+
488
+
489
+ def verify_record(document: Mapping[str, Any], trusted_keys: TrustedKeys) -> List[str]:
490
+ """Return the reasons *document* is not a verified record; empty means it is.
491
+
492
+ *trusted_keys* is the caller's trust anchor -- one raw ed25519 public
493
+ key or a sequence of them -- and the signature must verify under one
494
+ of them. It is deliberately not read out of the record: a key a
495
+ document carries about itself makes every self-consistent document
496
+ verify, including one an attacker rewrote and re-signed with a key of
497
+ their own. The record's own ``signer`` block is covered by the
498
+ signature, so it says who the *signer claimed* to be; whether that
499
+ signer is anyone the reader trusts is this argument's question.
500
+
501
+ A list rather than a boolean, for the same reason
502
+ :class:`~flux_engine.concordance.verifier.FidelityEvaluation` keeps
503
+ its refusals: the gate reports every reason it withheld a bundle, not
504
+ the first one it found. An unsigned-fallback record and a record whose
505
+ signature does not verify are two different reasons and say so.
506
+ """
507
+ reasons: List[str] = []
508
+ try:
509
+ record = load_record(document)
510
+ except FluxError as error:
511
+ return [error.detail or error.code.title]
512
+
513
+ signature = record.document.get("signature", {})
514
+ if not isinstance(signature, Mapping):
515
+ return ["the record's signature block is not a mapping"]
516
+ signer = record.document.get("signer", {})
517
+ if not isinstance(signer, Mapping):
518
+ return ["the record's signer block is not a mapping"]
519
+ alg = signature.get("alg")
520
+ if alg == UnsignedFallbackSigner.alg:
521
+ return ["the record states that it was not signed"]
522
+ if alg != "ed25519":
523
+ return [f"the record's signature algorithm {alg!r} is not one this verifier reads"]
524
+ if signer.get("alg") != alg or signer.get("keyId") != signature.get("keyId"):
525
+ return ["the record's signer block and its signature name different keys"]
526
+
527
+ anchors, anchor_reasons = _trust_anchors(trusted_keys)
528
+ reasons.extend(anchor_reasons)
529
+ if not anchors:
530
+ return [
531
+ *reasons,
532
+ "no trusted key was supplied to check the record's signature against",
533
+ ]
534
+
535
+ try:
536
+ raw = base64.b64decode(str(signature.get("sig", "")), validate=True)
537
+ except (ValueError, TypeError):
538
+ return [*reasons, "the record's signature is not base64"]
539
+
540
+ try:
541
+ from cryptography.exceptions import InvalidSignature
542
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
543
+ except ImportError: # pragma: no cover - cryptography is a declared dependency
544
+ return [*reasons, "cryptography is not installed, so the signature cannot be checked"]
545
+
546
+ payload = signing_payload(record.document)
547
+ for anchor in anchors:
548
+ try:
549
+ Ed25519PublicKey.from_public_bytes(anchor).verify(raw, payload)
550
+ except InvalidSignature:
551
+ continue
552
+ return reasons
553
+ reasons.append("the record's signature does not verify against any trusted key")
554
+ return reasons