aevum-publish 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .venv/
7
+ *.egg-info/
8
+
9
+ # Build
10
+ dist/
11
+ build/
12
+ site/
13
+
14
+ # Tools
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .pytest_cache/
18
+ .hypothesis/
19
+ .cache/
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Verify scripts (run locally, never commit)
32
+ verify_*.py
33
+ scripts/verify_*.py
34
+
35
+ # Aevum development — never commit (Phase 0+)
36
+ aevum_principles.key
37
+ signed_principles_draft.yaml
38
+ tools/sign_principles.py
39
+
40
+ # Private keys — never commit
41
+ *.key
42
+ *.pem
43
+
44
+ # OpenSSF Scorecard output (Phase 0+)
45
+ results.sarif
46
+ verify_phase3.py
47
+ verify_phase7.py
48
+ verify_phase8.py
49
+ verify_phase*.py
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: aevum-publish
3
+ Version: 0.4.0
4
+ Summary: Aevum — Sigstore Rekor v2 transparency log complication.
5
+ Project-URL: Homepage, https://aevum.build
6
+ Project-URL: Repository, https://github.com/aevum-labs/aevum
7
+ License: Apache-2.0
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: aevum-core
15
+ Provides-Extra: dev
16
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
17
+ Requires-Dist: mypy>=1.10; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.9; extra == 'dev'
20
+ Provides-Extra: rekor
21
+ Requires-Dist: httpx>=0.27.0; extra == 'rekor'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # aevum-publish
25
+
26
+ Sigstore Rekor v2 transparency log complication for Aevum.
27
+
28
+ Submits periodic chain checkpoints to an external transparency log,
29
+ enabling adversarial-resistant verification: even if an operator is
30
+ compromised, they cannot silently replace the chain without the external
31
+ witness detecting the discrepancy.
32
+
33
+ ```bash
34
+ pip install aevum-publish[rekor]
35
+ ```
36
+
37
+ ```python
38
+ from aevum.core import Engine
39
+ from aevum.publish import PublishComplication
40
+
41
+ engine = Engine()
42
+ comp = PublishComplication(
43
+ rekor_url="https://rekor.sigstore.dev", # or private Rekor
44
+ every_n_events=100, # checkpoint every 100 events
45
+ every_seconds=300, # or every 5 minutes
46
+ )
47
+ engine.install_complication(comp)
48
+ engine.approve_complication("aevum-publish")
49
+ comp.on_approved(engine) # must be called explicitly
50
+ # Chain now contains signed transparency.checkpoint events with Rekor inclusion proofs
51
+ ```
52
+
53
+ ## Checkpoint format
54
+
55
+ Each checkpoint is a SHA-256 digest of:
56
+ ```json
57
+ {"prior_hash": "...", "sequence": 42, "signer_key_id": "...", "system_time": ...}
58
+ ```
59
+
60
+ Submitted to Rekor as a `hashedrekord` entry. The Rekor log index and
61
+ inclusion proof are stored in the local sigchain as a `transparency.checkpoint`
62
+ AuditEvent, so the chain self-documents its verification history.
63
+
64
+ ## Private Rekor
65
+
66
+ For confidential deployments where checkpoint hashes must not be public:
67
+
68
+ ```python
69
+ comp = PublishComplication(rekor_url="https://your-private-rekor.example.com")
70
+ ```
71
+
72
+ ## Without Rekor
73
+
74
+ If `httpx` is not installed or the Rekor endpoint is unreachable, the
75
+ complication logs a warning and continues. The Engine write path is never
76
+ blocked.
77
+
78
+ ## Environment variables
79
+
80
+ | Variable | Default | Description |
81
+ |---|---|---|
82
+ | `AEVUM_PUBLISH_EVERY_N_EVENTS` | `100` | Submit checkpoint after N events |
83
+ | `AEVUM_PUBLISH_EVERY_SECONDS` | `300` | Submit checkpoint after N seconds |
84
+
85
+ ## See also
86
+
87
+ - [ADR-007: Transparency log](../../docs/adrs/adr-007-transparency-log.md)
88
+ - [Sigstore Rekor v2](https://github.com/sigstore/rekor-tiles)
@@ -0,0 +1,65 @@
1
+ # aevum-publish
2
+
3
+ Sigstore Rekor v2 transparency log complication for Aevum.
4
+
5
+ Submits periodic chain checkpoints to an external transparency log,
6
+ enabling adversarial-resistant verification: even if an operator is
7
+ compromised, they cannot silently replace the chain without the external
8
+ witness detecting the discrepancy.
9
+
10
+ ```bash
11
+ pip install aevum-publish[rekor]
12
+ ```
13
+
14
+ ```python
15
+ from aevum.core import Engine
16
+ from aevum.publish import PublishComplication
17
+
18
+ engine = Engine()
19
+ comp = PublishComplication(
20
+ rekor_url="https://rekor.sigstore.dev", # or private Rekor
21
+ every_n_events=100, # checkpoint every 100 events
22
+ every_seconds=300, # or every 5 minutes
23
+ )
24
+ engine.install_complication(comp)
25
+ engine.approve_complication("aevum-publish")
26
+ comp.on_approved(engine) # must be called explicitly
27
+ # Chain now contains signed transparency.checkpoint events with Rekor inclusion proofs
28
+ ```
29
+
30
+ ## Checkpoint format
31
+
32
+ Each checkpoint is a SHA-256 digest of:
33
+ ```json
34
+ {"prior_hash": "...", "sequence": 42, "signer_key_id": "...", "system_time": ...}
35
+ ```
36
+
37
+ Submitted to Rekor as a `hashedrekord` entry. The Rekor log index and
38
+ inclusion proof are stored in the local sigchain as a `transparency.checkpoint`
39
+ AuditEvent, so the chain self-documents its verification history.
40
+
41
+ ## Private Rekor
42
+
43
+ For confidential deployments where checkpoint hashes must not be public:
44
+
45
+ ```python
46
+ comp = PublishComplication(rekor_url="https://your-private-rekor.example.com")
47
+ ```
48
+
49
+ ## Without Rekor
50
+
51
+ If `httpx` is not installed or the Rekor endpoint is unreachable, the
52
+ complication logs a warning and continues. The Engine write path is never
53
+ blocked.
54
+
55
+ ## Environment variables
56
+
57
+ | Variable | Default | Description |
58
+ |---|---|---|
59
+ | `AEVUM_PUBLISH_EVERY_N_EVENTS` | `100` | Submit checkpoint after N events |
60
+ | `AEVUM_PUBLISH_EVERY_SECONDS` | `300` | Submit checkpoint after N seconds |
61
+
62
+ ## See also
63
+
64
+ - [ADR-007: Transparency log](../../docs/adrs/adr-007-transparency-log.md)
65
+ - [Sigstore Rekor v2](https://github.com/sigstore/rekor-tiles)
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "aevum-publish"
3
+ version = "0.4.0"
4
+ description = "Aevum — Sigstore Rekor v2 transparency log complication."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = { text = "Apache-2.0" }
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: Apache Software License",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Typing :: Typed",
14
+ ]
15
+ dependencies = [
16
+ "aevum-core",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ rekor = ["httpx>=0.27.0"]
21
+ dev = [
22
+ "pytest>=8.0",
23
+ "mypy>=1.10",
24
+ "ruff>=0.9",
25
+ "httpx>=0.27.0",
26
+ ]
27
+
28
+ [project.entry-points."aevum.complications"]
29
+ publish = "aevum.publish.complication:PublishComplication"
30
+
31
+ [project.urls]
32
+ Homepage = "https://aevum.build"
33
+ Repository = "https://github.com/aevum-labs/aevum"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/aevum"]
41
+
42
+ [tool.uv.sources]
43
+ aevum-core = { workspace = true }
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = "--tb=short"
48
+ pythonpath = ["src", "../../packages/aevum-core/src"]
49
+
50
+ [tool.mypy]
51
+ strict = true
52
+ python_version = "3.11"
53
+ mypy_path = "src"
54
+ explicit_package_bases = true
55
+ ignore_missing_imports = true
56
+
57
+ [tool.ruff]
58
+ line-length = 100
59
+
60
+ [tool.ruff.lint]
61
+ select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
62
+ ignore = ["ANN401"]
@@ -0,0 +1,28 @@
1
+ """
2
+ aevum.publish — Sigstore Rekor v2 transparency log complication.
3
+
4
+ Submits chain checkpoints to an external transparency log, enabling
5
+ adversarial-resistant chain verification. Without external witnessing,
6
+ a compromised operator could silently replace the entire chain.
7
+
8
+ Requires: pip install aevum-publish[rekor] (installs httpx)
9
+
10
+ Without httpx installed: importing succeeds, but checkpoint submission
11
+ warns and skips gracefully.
12
+
13
+ Usage:
14
+ from aevum.publish import PublishComplication
15
+ comp = PublishComplication(
16
+ rekor_url="https://rekor.sigstore.dev", # or private Rekor
17
+ every_n_events=100,
18
+ every_seconds=300,
19
+ )
20
+ engine.install_complication(comp)
21
+ engine.approve_complication("aevum-publish")
22
+ comp.on_approved(engine) # must be called explicitly — Engine does not auto-call
23
+ """
24
+
25
+ from aevum.publish.complication import PublishComplication
26
+
27
+ __version__ = "0.4.0"
28
+ __all__ = ["PublishComplication"]
@@ -0,0 +1,271 @@
1
+ """
2
+ PublishComplication — Rekor v2 transparency log integration.
3
+
4
+ ADR-007 implementation. See docs/adrs/adr-007-transparency-log.md.
5
+
6
+ Lifecycle note: the Engine has no automatic on_approved callback mechanism.
7
+ Callers must invoke comp.on_approved(engine) explicitly after
8
+ engine.approve_complication("aevum-publish") to trigger checkpoint submission.
9
+ This matches the pattern established by aevum-spiffe.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import logging
17
+ import os
18
+ import threading
19
+ import time
20
+ from typing import Any
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ _DEFAULT_REKOR_URL = "https://rekor.sigstore.dev"
25
+ _DEFAULT_EVERY_N = int(os.environ.get("AEVUM_PUBLISH_EVERY_N_EVENTS", "100"))
26
+ _DEFAULT_EVERY_S = int(os.environ.get("AEVUM_PUBLISH_EVERY_SECONDS", "300"))
27
+
28
+
29
+ def _compute_checkpoint_digest(
30
+ sequence: int,
31
+ prior_hash: str,
32
+ signer_key_id: str,
33
+ system_time: int,
34
+ ) -> bytes:
35
+ """
36
+ SHA-256 digest of the canonical checkpoint record.
37
+
38
+ Using SHA-256 (not SHA3-256) because Rekor's hashedrekord spec requires SHA-256.
39
+ The chain's internal integrity uses SHA3-256; the external witness uses SHA-256.
40
+ These are separate trust layers with different hash requirements.
41
+ """
42
+ record = json.dumps(
43
+ {
44
+ "sequence": sequence,
45
+ "prior_hash": prior_hash,
46
+ "signer_key_id": signer_key_id,
47
+ "system_time": system_time,
48
+ },
49
+ sort_keys=True,
50
+ separators=(",", ":"),
51
+ ).encode("utf-8")
52
+ return hashlib.sha256(record).digest()
53
+
54
+
55
+ class PublishComplication:
56
+ """
57
+ Transparency log complication. Submits chain checkpoints to Rekor v2.
58
+
59
+ Threshold triggers (whichever comes first):
60
+ - every_n_events: submit after N events are written to the chain
61
+ - every_seconds: submit after M seconds since last submission
62
+
63
+ Failure modes:
64
+ - httpx not installed: warn, skip
65
+ - Rekor unreachable: warn, buffer for next threshold
66
+ - Submission rejected: warn, log details
67
+
68
+ NEVER blocks the Engine write path. NEVER raises in lifecycle hooks.
69
+
70
+ Engine integration (no automatic lifecycle hook):
71
+ engine.install_complication(comp)
72
+ engine.approve_complication("aevum-publish")
73
+ comp.on_approved(engine) # caller must invoke this explicitly
74
+ """
75
+
76
+ name: str = "aevum-publish"
77
+ version: str = "0.4.0"
78
+
79
+ def __init__(
80
+ self,
81
+ rekor_url: str | None = None,
82
+ every_n_events: int | None = None,
83
+ every_seconds: int | None = None,
84
+ ) -> None:
85
+ self._rekor_url = (rekor_url or _DEFAULT_REKOR_URL).rstrip("/")
86
+ self._every_n = every_n_events or _DEFAULT_EVERY_N
87
+ self._every_s = every_seconds or _DEFAULT_EVERY_S
88
+ self._engine: Any = None
89
+ self._events_since_checkpoint: int = 0
90
+ self._last_checkpoint_time: float = time.monotonic()
91
+ self._lock = threading.Lock()
92
+ self._enabled: bool = False
93
+
94
+ def manifest(self) -> dict[str, Any]:
95
+ return {
96
+ "name": self.name,
97
+ "version": self.version,
98
+ "description": "Sigstore Rekor v2 transparency log checkpoints for chain verification",
99
+ "capabilities": ["transparency-log"],
100
+ "classification_max": 0,
101
+ "functions": ["commit"],
102
+ "auth": {"scopes_required": [], "public_key": None},
103
+ "schema_version": "1.0",
104
+ }
105
+
106
+ # ── Lifecycle hook ────────────────────────────────────────────────────────
107
+ # Mirrors aevum-spiffe pattern: Engine does not call this automatically.
108
+ # Callers must invoke comp.on_approved(engine) explicitly after
109
+ # engine.approve_complication("aevum-publish").
110
+
111
+ def on_approved(self, engine: Any) -> None:
112
+ """
113
+ Call after engine.approve_complication("aevum-publish") to trigger
114
+ initial checkpoint submission and enable event counting.
115
+
116
+ The Engine does not call this automatically; callers must invoke it.
117
+ """
118
+ self._engine = engine
119
+ self._enabled = True
120
+ self._try_submit_checkpoint(reason="complication.approved")
121
+
122
+ # ── Event counting hook ───────────────────────────────────────────────────
123
+
124
+ def on_event_written(self) -> None:
125
+ """
126
+ Call after each successful event write to the chain. Checks both
127
+ thresholds and submits a checkpoint if either is met.
128
+ """
129
+ if not self._enabled:
130
+ return
131
+
132
+ with self._lock:
133
+ self._events_since_checkpoint += 1
134
+ n_trigger = self._events_since_checkpoint >= self._every_n
135
+ t_trigger = (time.monotonic() - self._last_checkpoint_time) >= self._every_s
136
+
137
+ if n_trigger or t_trigger:
138
+ reason = "n_events" if n_trigger else "interval"
139
+ self._try_submit_checkpoint(reason=reason)
140
+
141
+ # ── Checkpoint submission ─────────────────────────────────────────────────
142
+
143
+ def _try_submit_checkpoint(self, reason: str = "threshold") -> None:
144
+ """
145
+ Attempt to submit a checkpoint to Rekor. Silently degrades on failure.
146
+ Resets event counter and timer on success.
147
+ """
148
+ if self._engine is None:
149
+ return
150
+
151
+ try:
152
+ entries = self._engine.get_ledger_entries()
153
+ except Exception as exc:
154
+ log.warning("aevum-publish: could not read ledger: %s", exc)
155
+ return
156
+
157
+ if not entries:
158
+ return
159
+
160
+ last = entries[-1]
161
+ sequence = last.get("sequence", 0)
162
+ prior_hash = last.get("prior_hash", "")
163
+ signer_key_id = last.get("signer_key_id", "")
164
+ system_time = last.get("system_time", 0)
165
+
166
+ digest = _compute_checkpoint_digest(
167
+ sequence, prior_hash, signer_key_id, system_time
168
+ )
169
+
170
+ try:
171
+ log_index, entry_hash = self._submit_to_rekor(digest)
172
+ except Exception as exc:
173
+ log.warning(
174
+ "aevum-publish: checkpoint submission failed (%s). "
175
+ "Will retry at next threshold.",
176
+ exc,
177
+ )
178
+ return
179
+
180
+ self._write_checkpoint_event(
181
+ log_index=log_index,
182
+ entry_hash=entry_hash,
183
+ chain_sequence=sequence,
184
+ chain_prior_hash=prior_hash,
185
+ reason=reason,
186
+ )
187
+
188
+ with self._lock:
189
+ self._events_since_checkpoint = 0
190
+ self._last_checkpoint_time = time.monotonic()
191
+
192
+ log.info(
193
+ "aevum-publish: checkpoint submitted — sequence=%d rekor_index=%d",
194
+ sequence,
195
+ log_index,
196
+ )
197
+
198
+ def _submit_to_rekor(self, digest: bytes) -> tuple[int, str]:
199
+ """
200
+ Submit a SHA-256 digest to Rekor v2 as a hashedrekord entry.
201
+ Returns (log_index, entry_hash).
202
+ Raises ImportError if httpx not installed.
203
+ Raises httpx.HTTPError on submission failure.
204
+
205
+ NOTE: The format below matches the Rekor v1 hashedrekord spec. Rekor v2
206
+ (rekor-tiles) uses a different tile-based API; verify against
207
+ https://github.com/sigstore/rekor-tiles/blob/main/CLIENTS.md before
208
+ production use.
209
+ """
210
+ try:
211
+ import httpx # noqa: PLC0415
212
+ except ImportError as exc:
213
+ raise ImportError(
214
+ "aevum-publish requires httpx. "
215
+ "Install with: pip install aevum-publish[rekor]"
216
+ ) from exc
217
+
218
+ digest_hex = digest.hex()
219
+
220
+ body = {
221
+ "apiVersion": "0.0.1",
222
+ "kind": "hashedrekord",
223
+ "spec": {
224
+ "data": {
225
+ "hash": {
226
+ "algorithm": "sha256",
227
+ "value": digest_hex,
228
+ }
229
+ },
230
+ },
231
+ }
232
+
233
+ resp = httpx.post(
234
+ f"{self._rekor_url}/api/v1/log/entries",
235
+ json=body,
236
+ timeout=30.0,
237
+ )
238
+ resp.raise_for_status()
239
+
240
+ data = resp.json()
241
+ uuid_key = next(iter(data))
242
+ entry = data[uuid_key]
243
+ log_index = int(entry.get("logIndex", entry.get("log_index", -1)))
244
+ return log_index, uuid_key
245
+
246
+ def _write_checkpoint_event(
247
+ self,
248
+ log_index: int,
249
+ entry_hash: str,
250
+ chain_sequence: int,
251
+ chain_prior_hash: str,
252
+ reason: str,
253
+ ) -> None:
254
+ """Write transparency.checkpoint AuditEvent to the local sigchain."""
255
+ try:
256
+ self._engine._ledger.append(
257
+ event_type="transparency.checkpoint",
258
+ payload={
259
+ "rekor_log_index": log_index,
260
+ "rekor_entry_hash": entry_hash,
261
+ "rekor_server": self._rekor_url,
262
+ "chain_sequence": chain_sequence,
263
+ "chain_prior_hash": chain_prior_hash,
264
+ "checkpoint_reason": reason,
265
+ },
266
+ actor="aevum-publish",
267
+ )
268
+ except Exception as exc:
269
+ log.error(
270
+ "aevum-publish: failed to write transparency.checkpoint: %s", exc
271
+ )
File without changes
@@ -0,0 +1,222 @@
1
+ """
2
+ Tests for PublishComplication.
3
+ Uses mocked httpx and Engine — no real Rekor instance required.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import sys
10
+ import time
11
+ import unittest.mock
12
+
13
+
14
+ def _make_mock_engine(entries: list[dict] | None = None) -> unittest.mock.MagicMock:
15
+ """Minimal engine mock with configurable ledger entries."""
16
+ engine = unittest.mock.MagicMock()
17
+ engine.get_ledger_entries.return_value = entries or [
18
+ {
19
+ "sequence": 3,
20
+ "prior_hash": "a" * 64,
21
+ "signer_key_id": "test-key-id",
22
+ "system_time": 1746000000000000000,
23
+ "event_type": "session.start",
24
+ "actor": "aevum-core",
25
+ }
26
+ ]
27
+ engine._ledger.append = unittest.mock.MagicMock(return_value=None)
28
+ return engine
29
+
30
+
31
+ def _make_mock_rekor_response(
32
+ log_index: int = 42, uuid: str = "abc123def456"
33
+ ) -> unittest.mock.MagicMock:
34
+ """Mock a successful Rekor submission response."""
35
+ mock_resp = unittest.mock.MagicMock()
36
+ mock_resp.status_code = 201
37
+ mock_resp.raise_for_status = unittest.mock.MagicMock(return_value=None)
38
+ mock_resp.json.return_value = {uuid: {"logIndex": log_index, "body": "..."}}
39
+ return mock_resp
40
+
41
+
42
+ class TestPublishComplicationCheckpoint:
43
+
44
+ def test_on_approved_submits_initial_checkpoint(self) -> None:
45
+ """Approval must trigger an immediate initial checkpoint."""
46
+ from aevum.publish import PublishComplication
47
+
48
+ mock_engine = _make_mock_engine()
49
+ comp = PublishComplication(rekor_url="https://mock.rekor.test")
50
+ mock_resp = _make_mock_rekor_response(log_index=1, uuid="uuid-0001")
51
+
52
+ with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
53
+ comp.on_approved(mock_engine)
54
+
55
+ mock_post.assert_called_once()
56
+ mock_engine._ledger.append.assert_called_once()
57
+ call_kwargs = mock_engine._ledger.append.call_args.kwargs
58
+ assert call_kwargs["event_type"] == "transparency.checkpoint"
59
+ assert call_kwargs["actor"] == "aevum-publish"
60
+ payload = call_kwargs["payload"]
61
+ assert payload["rekor_log_index"] == 1
62
+ assert payload["rekor_entry_hash"] == "uuid-0001"
63
+ assert payload["rekor_server"] == "https://mock.rekor.test"
64
+
65
+ def test_n_events_threshold_triggers_checkpoint(self) -> None:
66
+ """After N events, on_event_written must submit a checkpoint."""
67
+ from aevum.publish import PublishComplication
68
+
69
+ mock_engine = _make_mock_engine()
70
+ comp = PublishComplication(
71
+ rekor_url="https://mock.rekor.test",
72
+ every_n_events=3,
73
+ every_seconds=9999,
74
+ )
75
+ mock_resp = _make_mock_rekor_response()
76
+
77
+ with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
78
+ comp.on_approved(mock_engine) # initial checkpoint (call 1)
79
+ mock_post.reset_mock()
80
+ mock_engine._ledger.append.reset_mock()
81
+
82
+ comp.on_event_written() # 1 of 3
83
+ comp.on_event_written() # 2 of 3
84
+ assert not mock_post.called # not yet
85
+
86
+ comp.on_event_written() # 3 of 3 — threshold reached
87
+
88
+ mock_post.assert_called_once()
89
+ mock_engine._ledger.append.assert_called_once()
90
+ payload = mock_engine._ledger.append.call_args.kwargs["payload"]
91
+ assert payload["checkpoint_reason"] == "n_events"
92
+
93
+ def test_time_threshold_triggers_checkpoint(self) -> None:
94
+ """After every_seconds, on_event_written must submit a checkpoint."""
95
+ from aevum.publish import PublishComplication
96
+
97
+ mock_engine = _make_mock_engine()
98
+ comp = PublishComplication(
99
+ rekor_url="https://mock.rekor.test",
100
+ every_n_events=9999,
101
+ every_seconds=1,
102
+ )
103
+ mock_resp = _make_mock_rekor_response()
104
+
105
+ with unittest.mock.patch("httpx.post", return_value=mock_resp) as mock_post:
106
+ comp.on_approved(mock_engine)
107
+ mock_post.reset_mock()
108
+ mock_engine._ledger.append.reset_mock()
109
+
110
+ comp._last_checkpoint_time = time.monotonic() - 2.0
111
+ comp.on_event_written()
112
+
113
+ mock_post.assert_called_once()
114
+ payload = mock_engine._ledger.append.call_args.kwargs["payload"]
115
+ assert payload["checkpoint_reason"] == "interval"
116
+
117
+ def test_rekor_failure_does_not_raise(self) -> None:
118
+ """Rekor submission failure must warn and not raise."""
119
+ from aevum.publish import PublishComplication
120
+
121
+ mock_engine = _make_mock_engine()
122
+ comp = PublishComplication(
123
+ rekor_url="https://mock.rekor.test",
124
+ every_n_events=1,
125
+ every_seconds=9999,
126
+ )
127
+
128
+ import httpx
129
+
130
+ with unittest.mock.patch(
131
+ "httpx.post", side_effect=httpx.ConnectError("Connection refused")
132
+ ):
133
+ comp.on_approved(mock_engine)
134
+ comp.on_event_written()
135
+
136
+ assert mock_engine._ledger.append.call_count == 0
137
+
138
+ def test_httpx_missing_degrades_gracefully(self) -> None:
139
+ """Missing httpx must warn and not raise."""
140
+ from aevum.publish import PublishComplication
141
+
142
+ mock_engine = _make_mock_engine()
143
+ comp = PublishComplication()
144
+
145
+ with unittest.mock.patch.dict(sys.modules, {"httpx": None}):
146
+ comp.on_approved(mock_engine)
147
+
148
+ mock_engine._ledger.append.assert_not_called()
149
+
150
+ def test_checkpoint_digest_is_deterministic(self) -> None:
151
+ """Same inputs must always produce same digest."""
152
+ from aevum.publish.complication import _compute_checkpoint_digest
153
+
154
+ d1 = _compute_checkpoint_digest(42, "a" * 64, "key-id", 1746000000)
155
+ d2 = _compute_checkpoint_digest(42, "a" * 64, "key-id", 1746000000)
156
+ assert d1 == d2
157
+ assert len(d1) == 32 # SHA-256 = 32 bytes
158
+
159
+ def test_checkpoint_digest_uses_sha256_not_sha3(self) -> None:
160
+ """Rekor expects SHA-256; chain internal integrity uses SHA3-256."""
161
+ from aevum.publish.complication import _compute_checkpoint_digest
162
+
163
+ digest = _compute_checkpoint_digest(1, "0" * 64, "k", 0)
164
+ record = json.dumps(
165
+ {
166
+ "sequence": 1,
167
+ "prior_hash": "0" * 64,
168
+ "signer_key_id": "k",
169
+ "system_time": 0,
170
+ },
171
+ sort_keys=True,
172
+ separators=(",", ":"),
173
+ ).encode()
174
+ expected = hashlib.sha256(record).digest()
175
+ assert digest == expected
176
+
177
+ def test_rekor_url_trailing_slash_stripped(self) -> None:
178
+ """Trailing slash in rekor_url must not double-slash the endpoint."""
179
+ from aevum.publish import PublishComplication
180
+
181
+ comp = PublishComplication(rekor_url="https://rekor.example.com/")
182
+ assert not comp._rekor_url.endswith("/")
183
+
184
+
185
+ class TestPublishComplicationIntegration:
186
+
187
+ def test_transparency_checkpoint_in_sigchain(self) -> None:
188
+ """transparency.checkpoint must be a verifiable, signed chain entry."""
189
+ # Phase 0 finding: Engine does not call on_approved automatically.
190
+ # Callers must invoke comp.on_approved(engine) explicitly after
191
+ # engine.approve_complication("aevum-publish"). Same pattern as aevum-spiffe.
192
+ sys.path.insert(0, "packages/aevum-core/src")
193
+ from aevum.core import Engine # noqa: I001
194
+ from aevum.publish import PublishComplication
195
+
196
+ mock_resp = _make_mock_rekor_response(log_index=99, uuid="test-uuid-publish")
197
+
198
+ comp = PublishComplication(
199
+ rekor_url="https://mock.rekor.test",
200
+ every_n_events=9999,
201
+ every_seconds=9999,
202
+ )
203
+
204
+ engine = Engine()
205
+
206
+ with unittest.mock.patch("httpx.post", return_value=mock_resp):
207
+ engine.install_complication(comp)
208
+ engine.approve_complication("aevum-publish")
209
+ comp.on_approved(engine) # must be called explicitly per aevum-spiffe pattern
210
+
211
+ entries = engine.get_ledger_entries()
212
+ event_types = [e["event_type"] for e in entries]
213
+
214
+ assert "transparency.checkpoint" in event_types, (
215
+ f"transparency.checkpoint not in chain. Events: {event_types}"
216
+ )
217
+ assert engine.verify_sigchain() is True
218
+
219
+ cp = next(e for e in entries if e["event_type"] == "transparency.checkpoint")
220
+ assert cp["payload"]["rekor_log_index"] == 99
221
+ assert cp["payload"]["rekor_server"] == "https://mock.rekor.test"
222
+ assert cp["actor"] == "aevum-publish"