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,22 @@
1
+ # Copyright 2026 Agentics Transformation Limited
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """FLUX Simulation Engine -- the runtime for FLUX bundles.
6
+
7
+ The sibling of the FLUID standard and CLI (``fluid``, ``data-product-forge``):
8
+ FLUX is the "wind tunnel" that runs a declarative simulation bundle and emits,
9
+ under a referenced FLUID contract, the exact rows that contract will see in
10
+ production.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib import metadata as _metadata
16
+
17
+ try:
18
+ __version__ = _metadata.version("flux-sim-engine")
19
+ except _metadata.PackageNotFoundError: # pragma: no cover - editable/dev tree, no metadata yet
20
+ __version__ = "0.2.0"
21
+
22
+ __all__ = ["__version__"]
flux_engine/_links.py ADDED
@@ -0,0 +1,55 @@
1
+ # Copyright 2026 Agentics Transformation Limited
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Where the documentation lives, in one place.
6
+
7
+ Every URL the command line prints comes from here: the docs link at the foot of
8
+ ``--help``, the page a failing command points at, the support link. That is what
9
+ keeps a printed URL stable. It is a function of a code or a command name and
10
+ nothing else, so a page can be moved by changing one constant, and a test
11
+ (``tests/docs/test_generated_docs.py``) holds that every URL this module builds
12
+ names a page that exists in ``docs/``.
13
+
14
+ The layout it relies on, relative to the root of these docs::
15
+
16
+ reference/cli/README.md every command <base>reference/cli/
17
+ reference/cli/<command>.md one command <base>reference/cli/<command>.html
18
+ reference/errors/README.md every FLX code <base>reference/errors/
19
+ reference/errors/FLX-nnnn.md one code <base>reference/errors/FLX-nnnn.html
20
+
21
+ The base is the address these docs are published at. They are written in this
22
+ repository and join the FLUX specification's site as its ``engine/`` folder. That
23
+ site is built with VuePress at ``base: '/flux/'``, which its config locks because
24
+ every published schema ``$id`` depends on it, so ``docs/engine/reference/errors/FLX-nnnn.md``
25
+ there is served as ``/flux/engine/reference/errors/FLX-nnnn.html``. Until the pages
26
+ are in the site they are not reachable: printing the address early is deliberate,
27
+ because the address is what has to stay stable.
28
+
29
+ **These addresses are public.** Every released version prints them, and a printed
30
+ address cannot be recalled. Moving a page means leaving something behind at the
31
+ old address. ``test_the_published_addresses_do_not_move`` in
32
+ ``tests/docs/test_generated_docs.py`` holds the literal strings, so a change to
33
+ the base or to the layout above fails a test instead of passing quietly because
34
+ the page and the address moved together.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ __all__ = ["DOCS_URL", "ISSUES_URL", "command_url", "error_url"]
40
+
41
+ #: The folder of the published site these docs live in. Ends with a slash.
42
+ DOCS_URL = "https://agenticstiger.github.io/flux/engine/"
43
+
44
+ #: Where a person reports a problem with the engine.
45
+ ISSUES_URL = "https://github.com/Agenticstiger/flux-engine/issues"
46
+
47
+
48
+ def error_url(code: str) -> str:
49
+ """The page that explains *code* (``FLX-nnnn``)."""
50
+ return f"{DOCS_URL}reference/errors/{code}.html"
51
+
52
+
53
+ def command_url(command: str = "") -> str:
54
+ """The reference page of one top-level command, or the index when *command* is empty."""
55
+ return f"{DOCS_URL}reference/cli/{command}.html" if command else f"{DOCS_URL}reference/cli/"
@@ -0,0 +1,5 @@
1
+ # Copyright 2026 Agentics Transformation Limited
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Vendored fallback copies of third-party packages flux_engine depends on."""
@@ -0,0 +1,19 @@
1
+ flux_spec (vendored copy)
2
+ Copyright 2026 The FLUX Project authors
3
+
4
+ This product includes software from the FLUX specification project
5
+ (https://github.com/Agenticstiger/flux), licensed under the Apache
6
+ License, Version 2.0. A copy of the License is available at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ See VENDOR-NOTES.md in this directory for the exact upstream commit this
11
+ copy was taken from and the minimal edits applied to make the validator,
12
+ enforcement gate, and bundler resolve their data paths from inside this
13
+ vendored package layout instead of a checked-out FLUX repository.
14
+
15
+ Unless required by applicable law or agreed to in writing, software
16
+ distributed under the License is distributed on an "AS IS" BASIS,
17
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ See the License for the specific language governing permissions and
19
+ limitations under the License.
@@ -0,0 +1,104 @@
1
+ # Vendor notes — `flux_engine/_vendor/flux_spec/`
2
+
3
+ ## Provenance
4
+
5
+ Copied verbatim (byte-for-byte, then edited as listed below) from
6
+ `Agenticstiger/flux`. The pinned commit below is the provenance; it is
7
+ reproducible from a clone of that repository anywhere.
8
+
9
+ **Upstream commit pinned:** `0181eeb21f35355c1f88bb8f06c8335493d94611`
10
+
11
+ Retrieved via `git show <commit>:<path>` rather than the working tree,
12
+ because the FLUX checkout had uncommitted, in-flight changes at copy time
13
+ (a `feat/flux-spec-package` branch already mid-refactor into a `flux_spec/`
14
+ package with `scripts/*.py` becoming compatibility shims — exactly the
15
+ public-distribution work owner decision 2 describes for a later
16
+ increment, W1). Pinning to the last commit keeps this vendor copy
17
+ reproducible and independent of that unmerged, unstable work.
18
+
19
+ Files copied, each verified byte-identical to the pinned blob before any
20
+ edit was applied:
21
+
22
+ | Vendored path | Upstream path (at the pinned commit) |
23
+ |---|---|
24
+ | `validate.py` | `scripts/validate.py` |
25
+ | `enforce.py` | `scripts/enforce.py` |
26
+ | `bundle.py` | `scripts/bundle.py` |
27
+ | `schema/*.json` (11 files) | `schema/*.json` |
28
+ | `vendor/fluid/*.json` (3 files) | `vendor/fluid/*.json` |
29
+ | `vectors/enforcement-vectors.json` | `tests/enforcement-vectors.json` |
30
+
31
+ `tests/test_regression.py` was read for context (not vendored — flux_engine
32
+ has its own test suite; `test_spec_shim.py` exercises this vendored copy
33
+ directly instead of re-running the upstream regression file).
34
+
35
+ ## Lines changed (minimal edits, path resolution only)
36
+
37
+ No logic, control flow, or behavior was changed. Every edit is a
38
+ `Path(__file__)`-relative constant, changed because this copy lives
39
+ directly inside the vendored package directory instead of two levels
40
+ below a repository root under `scripts/`.
41
+
42
+ ### `validate.py`
43
+
44
+ ```diff
45
+ -REPO = Path(__file__).resolve().parent.parent
46
+ +REPO = Path(__file__).resolve().parent
47
+ FLUX_SCHEMA_PATH = REPO / "schema" / "flux-schema-latest.json"
48
+ FLUID_VENDOR_DIR = REPO / "vendor" / "fluid"
49
+ ```
50
+
51
+ `FLUX_SCHEMA_PATH` and `FLUID_VENDOR_DIR` are unchanged — they already
52
+ resolve correctly once `REPO` points at this directory (which now itself
53
+ contains `schema/` and `vendor/fluid/`).
54
+
55
+ Left intentionally unfixed (inert in flux_engine's usage): `main()`'s
56
+ no-argument branch does `(REPO / "examples").iterdir()` to discover bundle
57
+ directories for the standalone `python3 scripts/validate.py` CLI. This
58
+ vendored copy has no `examples/` directory and `flux_engine.spec.validate`
59
+ never calls `main()` — only `Bundle`, `canonical_digest`,
60
+ `load_yaml_docs`, and `semver_satisfies` are imported by the shim
61
+ (`flux_engine/spec/validate.py`). If a future increment wants the
62
+ standalone CLI to work from this vendored copy, that branch needs its own
63
+ fix at that time.
64
+
65
+ ### `enforce.py`
66
+
67
+ ```diff
68
+ -REPO = Path(__file__).resolve().parent.parent
69
+ -DEFAULT_VECTORS = REPO / "tests" / "enforcement-vectors.json"
70
+ +REPO = Path(__file__).resolve().parent
71
+ +DEFAULT_VECTORS = REPO / "vectors" / "enforcement-vectors.json"
72
+ ```
73
+
74
+ The vectors file is vendored under `vectors/` (not `tests/`) per the task
75
+ spec, so the directory segment changed along with the `.parent` count.
76
+
77
+ ### `bundle.py`
78
+
79
+ ```diff
80
+ -REPO = Path(__file__).resolve().parent.parent
81
+ -sys.path.insert(0, str(REPO / "scripts"))
82
+ -from validate import Bundle, load_yaml_docs # noqa: E402
83
+ +REPO = Path(__file__).resolve().parent
84
+ +from .validate import Bundle, load_yaml_docs # noqa: E402
85
+ ```
86
+
87
+ `validate.py` is now a sibling module inside the same Python package
88
+ (`flux_engine._vendor.flux_spec`) rather than a same-directory *script*
89
+ next to `bundle.py` on `sys.path`, so the `sys.path.insert` + bare
90
+ top-level `import validate` becomes an ordinary package-relative import.
91
+ `import sys` was dropped from the top-of-file import block because the
92
+ removed `sys.path.insert` call was its only use in the module.
93
+
94
+ `SCHEMA_PATH`, `MANIFEST_SCHEMA_PATH`, and the `-o/--out` argparse default
95
+ (`REPO / "dist"`) are unchanged — all already resolve correctly under the
96
+ one-level-shallower `REPO`.
97
+
98
+ ## License
99
+
100
+ Apache License, Version 2.0. See `NOTICE` in this directory.
101
+
102
+ ## Provenance check (2026-09-06)
103
+
104
+ All vendored JSON files are byte-identical to `origin/main` (`35d979f`) of Agenticstiger/flux; the three Python modules are identical to `origin/main` except for the path edits listed above.
@@ -0,0 +1,9 @@
1
+ """Vendored fallback copy of the ``flux_spec`` distribution (Apache-2.0).
2
+
3
+ See ``NOTICE`` for attribution and ``VENDOR-NOTES.md`` for the upstream
4
+ commit this copy was taken from and every line changed to make it resolve
5
+ its data paths from inside this package instead of a checked-out FLUX
6
+ repository. ``flux_engine.spec`` is the only supported entry point into
7
+ this package from the rest of flux_engine — nothing else should import
8
+ ``flux_engine._vendor.flux_spec`` directly except to resolve a data path.
9
+ """
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ """FLUX evidence bundles (RFC-09).
3
+
4
+ Turns a validated bundle directory into a portable, verifiable artifact:
5
+
6
+ python3 scripts/bundle.py pack examples/telco-payment-recovery -o dist/
7
+ python3 scripts/bundle.py verify dist/telco-payment-recovery.flux.tgz
8
+
9
+ `pack` validates the bundle offline, writes `flux-manifest.json` (per-file
10
+ sha256, an RFC 6962 Merkle root, and an attestation of what the toolchain
11
+ could prove), emits a **deterministic** gzip archive, and writes an in-toto
12
+ Statement v1 for supply-chain tooling.
13
+
14
+ Determinism follows the reproducible-builds.org recipe: entries sorted by
15
+ name, mtime zeroed in both the tar entries and the gzip header, ownership
16
+ normalised to 0/0 with empty names, fixed mode, GNU format (PAX extended
17
+ headers can smuggle atime/ctime). Same inputs, same bytes — so two parties
18
+ can compare bundles by digest alone.
19
+
20
+ `verify` re-derives every digest and the Merkle root, re-validates the
21
+ documents, and checks the attestation against reality. It needs nothing from
22
+ the author: no engine, no network, no trust in the packer.
23
+
24
+ Signing is deliberately transport-agnostic and detached: sign the manifest
25
+ bytes with whatever your organisation already uses —
26
+
27
+ ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n flux flux-manifest.json
28
+ cosign sign-blob --key cosign.key flux-manifest.json
29
+ openssl dgst -sha256 -sign key.pem -out flux-manifest.sig flux-manifest.json
30
+
31
+ — and ship the signature alongside. Because the manifest commits to the
32
+ Merkle root, a signature over the manifest covers every byte of the bundle.
33
+
34
+ Profiles: an offline bundler may attest `core` (validator green) or
35
+ `enterprise` (validator green plus a Playback scorecard). It never attests
36
+ `runtime` — Level 3 requires a live engine, so claiming it from a packer
37
+ would be a lie.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import gzip
44
+ import hashlib
45
+ import io
46
+ import json
47
+ import shutil
48
+ import tarfile
49
+ import tempfile
50
+ from pathlib import Path
51
+
52
+ # VENDOR EDIT (see VENDOR-NOTES.md): one level shallower (see validate.py),
53
+ # and the sibling-module sys.path shim is replaced by a package-relative
54
+ # import now that validate.py is a sibling module inside the same package
55
+ # instead of a same-directory script. ``sys`` was only imported for the
56
+ # removed ``sys.path.insert`` call, so the import is dropped too.
57
+ REPO = Path(__file__).resolve().parent
58
+ from .validate import Bundle, load_yaml_docs # noqa: E402
59
+
60
+ from jsonschema import Draft202012Validator, FormatChecker # noqa: E402
61
+ from jsonschema.exceptions import ValidationError # noqa: E402
62
+
63
+
64
+ class _Rejected(Exception):
65
+ """Internal: stop verifying, the problem list already says why."""
66
+
67
+ SCHEMA_PATH = REPO / "schema" / "flux-schema-latest.json"
68
+ MANIFEST_SCHEMA_PATH = REPO / "schema" / "flux-manifest-0.5.0.json"
69
+ MANIFEST_NAME = "flux-manifest.json"
70
+ STATEMENT_SUFFIX = ".attestation.json"
71
+ PREDICATE_TYPE = "https://agenticstiger.github.io/flux/attestation/v1"
72
+ GENERATOR = "flux bundle 0.5.0"
73
+
74
+
75
+ def sha256_file(path: Path) -> str:
76
+ return hashlib.sha256(path.read_bytes()).hexdigest()
77
+
78
+
79
+ def merkle_root(files: dict[str, str]) -> str:
80
+ """Root over sorted (path, digest) pairs.
81
+
82
+ Domain separation follows RFC 6962 (Certificate Transparency): leaves are
83
+ hashed with a 0x00 prefix and interior nodes with 0x01, so an interior
84
+ hash can never be replayed as a leaf (the Merkle second-preimage attack).
85
+ An odd node is promoted unchanged.
86
+
87
+ One deliberate divergence from RFC 6962: the leaf commits to
88
+ `path\\0digest`, not to the file bytes alone. A manifest must bind *which*
89
+ file has which digest — without the path in the leaf, swapping the
90
+ contents of two manifested files would leave the root unchanged.
91
+
92
+ Paths are sorted by Unicode code point (Python's default), which is
93
+ locale-independent — unlike shell `sort`, a known reproducibility trap.
94
+ """
95
+ LEAF, NODE = b"\x00", b"\x01"
96
+ level = [
97
+ hashlib.sha256(LEAF + path.encode() + b"\x00" + bytes.fromhex(digest.split(":", 1)[1])).digest()
98
+ for path, digest in sorted(files.items())
99
+ ]
100
+ if not level:
101
+ return "sha256:" + hashlib.sha256(LEAF).hexdigest()
102
+ while len(level) > 1:
103
+ level = [
104
+ hashlib.sha256(NODE + level[i] + level[i + 1]).digest() if i + 1 < len(level) else level[i]
105
+ for i in range(0, len(level), 2)
106
+ ]
107
+ return "sha256:" + level[0].hex()
108
+
109
+
110
+ def intoto_statement(archive: Path, manifest: dict) -> dict:
111
+ """The attestation as an in-toto Statement v1 (in-toto.io/Statement/v1).
112
+
113
+ The manifest is the bundle's *internal* integrity structure; this is the
114
+ *interop* artifact. Shaping it as an in-toto Statement means existing
115
+ supply-chain tooling — cosign, in-toto-verify, anything that speaks DSSE —
116
+ can carry, sign and verify FLUX evidence without knowing what FLUX is:
117
+
118
+ cosign attest-blob --predicate flux-attestation.json \\
119
+ --type https://agenticstiger.github.io/flux/attestation/v1 <archive>
120
+
121
+ Note in-toto digests are bare hex keyed by algorithm, not the `sha256:`
122
+ prefixed form used inside the FLUX manifest.
123
+ """
124
+ return {
125
+ "_type": "https://in-toto.io/Statement/v1",
126
+ "subject": [{"name": archive.name, "digest": {"sha256": sha256_file(archive)}}],
127
+ "predicateType": PREDICATE_TYPE,
128
+ "predicate": {
129
+ "schemaId": manifest["schemaId"],
130
+ "fluxVersions": manifest["fluxVersions"],
131
+ "merkleRoot": manifest["merkleRoot"],
132
+ "fileCount": len(manifest["files"]),
133
+ **manifest["attestation"],
134
+ },
135
+ }
136
+
137
+
138
+ def bundle_files(directory: Path, skip_signatures: bool = True) -> dict[str, str]:
139
+ """Digest every file in the bundle.
140
+
141
+ The manifest is always excluded — it cannot contain its own digest.
142
+ Detached signatures are excluded while *packing* (they belong beside the
143
+ archive, not inside it), but never while *verifying*: an unmanifested
144
+ file must be reported no matter what it is called, or `evil.sig` becomes
145
+ a place to smuggle content past the digest check.
146
+ """
147
+ out = {}
148
+ for path in sorted(p for p in directory.rglob("*") if p.is_file()):
149
+ rel = path.relative_to(directory).as_posix()
150
+ if rel == MANIFEST_NAME or (skip_signatures and rel.endswith(".sig")):
151
+ continue
152
+ out[rel] = "sha256:" + sha256_file(path)
153
+ return out
154
+
155
+
156
+ def collect_attestation(directory: Path, validator) -> tuple[dict, list[str]]:
157
+ """Validate the bundle and describe what can honestly be attested."""
158
+ b = Bundle(directory, validator)
159
+ errors = b.run()
160
+ if errors:
161
+ return {}, errors
162
+
163
+ scorecards, seam, versions = [], [], set()
164
+ for doc_id, doc in b.flux_docs.items():
165
+ versions.add(doc.get("fluxVersion"))
166
+ spec = doc.get("spec", {})
167
+ if doc.get("kind") == "Playback" and isinstance(spec.get("scorecard"), dict):
168
+ sc = spec["scorecard"]
169
+ scorecards.append({"playbackId": doc_id, "grade": sc["grade"], "credibility": sc["credibility"]})
170
+ if doc.get("kind") == "Simulation":
171
+ for emit in spec.get("emits", []):
172
+ entry = {
173
+ "simulationId": doc_id,
174
+ "productRef": emit["productRef"],
175
+ "exposeId": emit["exposeId"],
176
+ "fluidVersion": emit["fluidVersion"],
177
+ }
178
+ digest = (emit.get("provenance") or {}).get("contractDigest")
179
+ if digest:
180
+ entry["contractDigest"] = digest
181
+ seam.append(entry)
182
+
183
+ attestation = {
184
+ "profile": "enterprise" if scorecards else "core",
185
+ "validator": {"status": "green", "documents": len(b.flux_docs)},
186
+ }
187
+ if scorecards:
188
+ attestation["scorecards"] = sorted(scorecards, key=lambda s: s["playbackId"])
189
+ if seam:
190
+ attestation["seam"] = seam
191
+ return {"attestation": attestation, "fluxVersions": sorted(v for v in versions if v)}, []
192
+
193
+
194
+ def build_manifest(directory: Path, validator) -> tuple[dict, list[str]]:
195
+ extra, errors = collect_attestation(directory, validator)
196
+ if errors:
197
+ return {}, errors
198
+ files = bundle_files(directory)
199
+ schema_id = json.loads(SCHEMA_PATH.read_text())["$id"]
200
+ manifest = {
201
+ "manifestVersion": 1,
202
+ "generator": GENERATOR,
203
+ "schemaId": schema_id,
204
+ "fluxVersions": extra["fluxVersions"],
205
+ "files": files,
206
+ "merkleRoot": merkle_root(files),
207
+ "attestation": extra["attestation"],
208
+ }
209
+ return manifest, []
210
+
211
+
212
+ def _deterministic_info(name: str, size: int) -> tarfile.TarInfo:
213
+ info = tarfile.TarInfo(name)
214
+ info.size = size
215
+ info.mtime = 0 # zeroed: same inputs -> same bytes
216
+ info.mode = 0o644
217
+ info.uid = info.gid = 0
218
+ info.uname = info.gname = ""
219
+ return info
220
+
221
+
222
+ def pack(directory: Path, out_dir: Path, validator) -> int:
223
+ directory = directory.resolve() # `pack .` must not become a nameless archive
224
+ if not directory.name:
225
+ print(f"[FAIL] {directory}: cannot determine a bundle name")
226
+ return 1
227
+ manifest, errors = build_manifest(directory, validator)
228
+ if errors:
229
+ print(f"[FAIL] {directory}: bundle does not validate — refusing to pack")
230
+ for e in errors[:10]:
231
+ print(f" {e}")
232
+ return 1
233
+ Draft202012Validator(json.loads(MANIFEST_SCHEMA_PATH.read_text())).validate(manifest)
234
+
235
+ (directory / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
236
+ out_dir.mkdir(parents=True, exist_ok=True)
237
+ archive = out_dir / f"{directory.name}.flux.tgz"
238
+
239
+ payload = dict(manifest["files"])
240
+ payload[MANIFEST_NAME] = "sha256:" + sha256_file(directory / MANIFEST_NAME)
241
+ raw = io.BytesIO()
242
+ # GNU format, not PAX: reproducible-builds.org warns PAX extended headers
243
+ # can carry atime/ctime (and PIDs under POSIXLY_CORRECT).
244
+ with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
245
+ for rel in sorted(payload):
246
+ data = (directory / rel).read_bytes()
247
+ tar.addfile(_deterministic_info(f"{directory.name}/{rel}", len(data)), io.BytesIO(data))
248
+ # gzip separately with mtime=0 — the gzip header carries its own timestamp,
249
+ # which would otherwise make every repack a different file.
250
+ with open(archive, "wb") as fh:
251
+ with gzip.GzipFile(filename="", mode="wb", compresslevel=9, fileobj=fh, mtime=0) as gz:
252
+ gz.write(raw.getvalue())
253
+
254
+ statement = intoto_statement(archive, manifest)
255
+ statement_path = out_dir / f"{directory.name}{STATEMENT_SUFFIX}"
256
+ statement_path.write_text(json.dumps(statement, indent=2, sort_keys=True) + "\n")
257
+
258
+ print(f"[ok] packed {archive}")
259
+ print(f" profile={manifest['attestation']['profile']} "
260
+ f"documents={manifest['attestation']['validator']['documents']} "
261
+ f"files={len(manifest['files'])}")
262
+ print(f" merkleRoot={manifest['merkleRoot']}")
263
+ print(f" archive sha256:{sha256_file(archive)}")
264
+ print(f" in-toto statement: {statement_path}")
265
+ print(f" sign it: ssh-keygen -Y sign -f <key> -n flux {directory / MANIFEST_NAME}")
266
+ print(f" or: cosign attest-blob --predicate {statement_path} --type {PREDICATE_TYPE} {archive}")
267
+ return 0
268
+
269
+
270
+ MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # declared, uncompressed, across all members
271
+ MAX_MEMBERS = 10_000
272
+ COPY_CHUNK = 1 << 20
273
+
274
+
275
+ def _extract_safely(tar: tarfile.TarFile, root: Path, problems: list[str]) -> Path | None:
276
+ """Materialise a verified-safe subset of the archive under `root`.
277
+
278
+ Everything hostile is rejected *before* anything is written: attacker-declared
279
+ sizes are budgeted first (gzip compresses zeros ~1000:1, so a 500 KB archive
280
+ can otherwise claim gigabytes), only regular files are extracted, and every
281
+ member must live under one shared top-level directory — a member outside it
282
+ would be extracted but never digested, which is exactly how undeclared
283
+ content sneaks past a manifest.
284
+ """
285
+ members = tar.getmembers()
286
+ if len(members) > MAX_MEMBERS:
287
+ problems.append(f"archive declares {len(members)} members, above the {MAX_MEMBERS} cap")
288
+ return None
289
+ declared = sum(m.size for m in members if m.isfile())
290
+ if declared > MAX_BUNDLE_BYTES:
291
+ problems.append(f"archive declares {declared} uncompressed bytes, above the {MAX_BUNDLE_BYTES} cap")
292
+ return None
293
+
294
+ tops = set()
295
+ for m in members:
296
+ name = Path(m.name)
297
+ if name.is_absolute() or ".." in name.parts or not name.parts:
298
+ problems.append(f"unsafe archive path: {m.name}")
299
+ return None
300
+ tops.add(name.parts[0])
301
+ if len(tops) != 1:
302
+ problems.append(f"expected exactly one bundle directory, found {sorted(tops) or 'none'}")
303
+ return None
304
+
305
+ for m in members:
306
+ if m.isdir():
307
+ continue
308
+ if not m.isfile():
309
+ problems.append(f"unsupported archive entry ({'symlink' if m.issym() or m.islnk() else 'special'}): {m.name}")
310
+ return None
311
+ if len(Path(m.name).parts) < 2:
312
+ problems.append(f"entry outside the bundle directory: {m.name}")
313
+ return None
314
+ target = root / m.name
315
+ if target.exists():
316
+ problems.append(f"duplicate archive entry: {m.name}")
317
+ return None
318
+ target.parent.mkdir(parents=True, exist_ok=True)
319
+ source = tar.extractfile(m)
320
+ if source is None:
321
+ problems.append(f"unreadable archive entry: {m.name}")
322
+ return None
323
+ with open(target, "wb") as fh:
324
+ shutil.copyfileobj(source, fh, COPY_CHUNK)
325
+ return root / tops.pop()
326
+
327
+
328
+ def verify(archive: Path, validator) -> int:
329
+ """Verify an archive offline. Always reports; never raises."""
330
+ problems: list[str] = []
331
+ manifest = {}
332
+ try:
333
+ with tempfile.TemporaryDirectory() as tmp:
334
+ root = Path(tmp)
335
+ with tarfile.open(archive, "r:gz") as tar:
336
+ directory = _extract_safely(tar, root, problems)
337
+ if directory is None:
338
+ raise _Rejected()
339
+
340
+ manifest_path = directory / MANIFEST_NAME
341
+ if not manifest_path.exists():
342
+ problems.append(f"no {MANIFEST_NAME}")
343
+ raise _Rejected()
344
+ manifest = json.loads(manifest_path.read_text())
345
+ Draft202012Validator(json.loads(MANIFEST_SCHEMA_PATH.read_text())).validate(manifest)
346
+
347
+ actual = bundle_files(directory, skip_signatures=False)
348
+ for rel, digest in manifest["files"].items():
349
+ if rel not in actual:
350
+ problems.append(f"missing file: {rel}")
351
+ elif actual[rel] != digest:
352
+ problems.append(f"digest mismatch: {rel}")
353
+ for rel in actual:
354
+ if rel not in manifest["files"]:
355
+ problems.append(f"unmanifested file: {rel}")
356
+ if merkle_root(manifest["files"]) != manifest["merkleRoot"]:
357
+ problems.append("merkleRoot does not match the manifest file list")
358
+
359
+ # Everything the manifest asserts is re-derived, never trusted.
360
+ expected_schema_id = json.loads(SCHEMA_PATH.read_text())["$id"]
361
+ if manifest["schemaId"] != expected_schema_id:
362
+ problems.append(f"schemaId {manifest['schemaId']} is not this toolchain's schema ({expected_schema_id})")
363
+ rebuilt, errors = collect_attestation(directory, validator)
364
+ if errors:
365
+ problems.append(f"bundle no longer validates ({len(errors)} errors, first: {errors[0]})")
366
+ else:
367
+ if rebuilt["attestation"] != manifest["attestation"]:
368
+ problems.append("attestation does not match a fresh validation of the bundle")
369
+ if rebuilt["fluxVersions"] != manifest["fluxVersions"]:
370
+ problems.append(f"fluxVersions {manifest['fluxVersions']} does not match the documents ({rebuilt['fluxVersions']})")
371
+ except _Rejected:
372
+ pass
373
+ except tarfile.ReadError as exc:
374
+ problems.append(f"not a readable gzip archive: {exc}")
375
+ except json.JSONDecodeError as exc:
376
+ problems.append(f"{MANIFEST_NAME} is not valid JSON: {exc}")
377
+ except ValidationError as exc:
378
+ problems.append(f"{MANIFEST_NAME} violates the manifest schema: {exc.message}")
379
+ except OSError as exc:
380
+ problems.append(f"cannot read archive: {exc}")
381
+
382
+ if problems:
383
+ print(f"[FAIL] {archive}")
384
+ for p in problems[:10]:
385
+ print(f" {p}")
386
+ return 1
387
+ print(f"[ok] {archive} — {len(manifest['files'])} files, merkle root verified, "
388
+ f"attestation reproduced (profile={manifest['attestation']['profile']})")
389
+ print(f" verify the signature separately, e.g. ssh-keygen -Y verify ... < {MANIFEST_NAME}")
390
+ return 0
391
+
392
+
393
+ def main(argv=None) -> int:
394
+ ap = argparse.ArgumentParser(description="FLUX evidence bundles (RFC-09)")
395
+ sub = ap.add_subparsers(dest="cmd", required=True)
396
+ p = sub.add_parser("pack", help="validate, manifest and archive a bundle")
397
+ p.add_argument("directory", type=Path)
398
+ p.add_argument("-o", "--out", type=Path, default=REPO / "dist")
399
+ v = sub.add_parser("verify", help="verify an archive offline")
400
+ v.add_argument("archive", type=Path)
401
+ args = ap.parse_args(argv)
402
+
403
+ validator = Draft202012Validator(json.loads(SCHEMA_PATH.read_text()), format_checker=FormatChecker())
404
+ if args.cmd == "pack":
405
+ return pack(args.directory, args.out, validator)
406
+ return verify(args.archive, validator)
407
+
408
+
409
+ if __name__ == "__main__":
410
+ raise SystemExit(main())