apsimo-hostworker 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.
@@ -0,0 +1,292 @@
1
+ """Strict loopback client for the governed-action execution endpoint.
2
+
3
+ The endpoint contract this client relies on is part of the safety boundary:
4
+
5
+ * ``PUT /v1/host/actions/{action_id}`` durably reserves the immutable action
6
+ as ``executing`` before any effect is attempted. That reservation permits
7
+ at most one mutation attempt and is never automatically replayed — so this
8
+ client NEVER retries a mutation. A ``PUT`` that fails, times out, or
9
+ returns garbage leaves the outcome unknown, and the only permitted
10
+ follow-up is read-only observation.
11
+ * ``GET /v1/host/actions/{action_id}`` is side-effect-free and returns the
12
+ endpoint's stable digest-bound projection of the action.
13
+
14
+ The client is loopback-only (see :func:`apsimo_hostworker._private_io.\
15
+ loopback_origin`), uses a redirect-refusing opener so the bearer credential
16
+ can never be replayed to another origin, and decodes responses through the
17
+ bounded strict JSON reader. It validates only the request document's outer
18
+ identity; semantic validation of responses belongs to the worker
19
+ (:func:`apsimo_hostworker.worker.validate_execution_result`).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import re
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from typing import Any, Mapping
31
+
32
+ from ._private_io import loopback_origin, read_private_json, strict_json_bytes
33
+ from .contract import (
34
+ ACTION_ID_RE,
35
+ EXECUTION_REQUEST_FIELDS,
36
+ EXECUTION_REQUEST_MAX_BYTES,
37
+ EXECUTION_REQUEST_SCHEMA,
38
+ EXECUTION_RESULT_MAX_BYTES,
39
+ canonical_json_utf8,
40
+ )
41
+
42
+
43
+ class GovernedActionClientError(RuntimeError):
44
+ """The execution endpoint is unavailable or returned invalid data."""
45
+
46
+
47
+ # The one principal the endpoint accepts for host-worker execution. It is a
48
+ # public wire string shared with the sidecar's independent validator
49
+ # (``GOVERNED_ACTION_PRINCIPAL``); never rename it.
50
+ WORKER_PRINCIPAL = "host-action-worker"
51
+
52
+ CREDENTIAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
53
+
54
+ _ACTIONS_PATH = "/v1/host/actions/"
55
+
56
+
57
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
58
+ """Refuse every redirect so credentials never leave the pinned origin.
59
+
60
+ Python's default redirect handler copies ordinary request headers —
61
+ including ``Authorization`` — to the redirect target. A loopback service
62
+ boundary must instead surface the redirect to its caller as an error.
63
+ """
64
+
65
+ def redirect_request(self, request, file_pointer, code, message, headers, new_url):
66
+ del request, file_pointer, code, message, headers, new_url
67
+ return None
68
+
69
+
70
+ def build_no_redirect_opener():
71
+ """Build a proxy-free, redirect-refusing ``urllib`` opener."""
72
+
73
+ return urllib.request.build_opener(
74
+ urllib.request.ProxyHandler({}), NoRedirectHandler()
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ClientCredential:
80
+ """Dedicated client identity for only the governed-action endpoint.
81
+
82
+ Loaded exclusively from an owner-only mode-0600 regular file so a
83
+ world-readable secret, a symlink swap, or a group-readable deploy
84
+ artifact fails closed at startup instead of leaking authority.
85
+ """
86
+
87
+ principal: str
88
+ credential_id: str
89
+ secret: str = field(repr=False)
90
+
91
+ @classmethod
92
+ def load(cls, path: str) -> "ClientCredential":
93
+ _target, _raw, document = read_private_json(
94
+ path,
95
+ label="governed action credential",
96
+ error=GovernedActionClientError,
97
+ )
98
+ fields = {"version", "principal", "credential_id", "secret"}
99
+ if not isinstance(document, Mapping) or set(document) != fields:
100
+ raise GovernedActionClientError(
101
+ "governed action credential fields are invalid"
102
+ )
103
+ if (
104
+ isinstance(document.get("version"), bool)
105
+ or document.get("version") != 1
106
+ or document.get("principal") != WORKER_PRINCIPAL
107
+ ):
108
+ raise GovernedActionClientError(
109
+ "governed action credential principal is invalid"
110
+ )
111
+ credential_id = document.get("credential_id")
112
+ secret = document.get("secret")
113
+ if not isinstance(credential_id, str) or not CREDENTIAL_ID_RE.fullmatch(
114
+ credential_id
115
+ ):
116
+ raise GovernedActionClientError(
117
+ "governed action credential ID is invalid"
118
+ )
119
+ if (
120
+ not isinstance(secret, str)
121
+ or not 32 <= len(secret) <= 512
122
+ or any(
123
+ ord(character) < 0x21 or ord(character) > 0x7E
124
+ for character in secret
125
+ )
126
+ ):
127
+ raise GovernedActionClientError(
128
+ "governed action credential secret is invalid"
129
+ )
130
+ return cls(
131
+ principal=WORKER_PRINCIPAL,
132
+ credential_id=credential_id,
133
+ secret=secret,
134
+ )
135
+
136
+
137
+ class GovernedActionClient:
138
+ """Credential-bound client for one loopback governed-action origin.
139
+
140
+ ``execute`` issues exactly one ``PUT`` per call and NEVER retries it —
141
+ the caller's state machine owns the one-mutation guarantee and must treat
142
+ any failure here as an unknown outcome to be resolved by ``observe``
143
+ only. ``observe`` issues a side-effect-free ``GET``.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ origin: str,
149
+ credential: ClientCredential,
150
+ *,
151
+ opener=None,
152
+ timeout: float = 5.0,
153
+ ) -> None:
154
+ if not isinstance(credential, ClientCredential):
155
+ raise GovernedActionClientError(
156
+ "governed action credential is invalid"
157
+ )
158
+ if isinstance(timeout, bool):
159
+ raise GovernedActionClientError("governed action timeout is invalid")
160
+ try:
161
+ request_timeout = float(timeout)
162
+ except (TypeError, ValueError, OverflowError) as error:
163
+ raise GovernedActionClientError(
164
+ "governed action timeout is invalid"
165
+ ) from error
166
+ if not math.isfinite(request_timeout) or not 0.1 <= request_timeout <= 30.0:
167
+ raise GovernedActionClientError("governed action timeout is invalid")
168
+ candidate = build_no_redirect_opener() if opener is None else opener
169
+ if not hasattr(candidate, "open") or not callable(candidate.open):
170
+ raise GovernedActionClientError(
171
+ "governed action HTTP opener is invalid"
172
+ )
173
+ self.origin = loopback_origin(origin, error=GovernedActionClientError)
174
+ self.credential = credential
175
+ self.timeout = request_timeout
176
+ self.opener = candidate
177
+
178
+ @staticmethod
179
+ def _action_id(request: Mapping[str, Any]) -> str:
180
+ if (
181
+ not isinstance(request, Mapping)
182
+ or set(request) != EXECUTION_REQUEST_FIELDS
183
+ or request.get("schema") != EXECUTION_REQUEST_SCHEMA
184
+ or isinstance(request.get("version"), bool)
185
+ or request.get("version") != 1
186
+ ):
187
+ raise GovernedActionClientError(
188
+ "governed action request fields are invalid"
189
+ )
190
+ action_id = request.get("action_id")
191
+ if not isinstance(action_id, str) or not ACTION_ID_RE.fullmatch(action_id):
192
+ raise GovernedActionClientError("governed action ID is invalid")
193
+ return action_id
194
+
195
+ def _request(self, method: str, request: Mapping[str, Any]) -> Mapping[str, Any]:
196
+ action_id = self._action_id(request)
197
+ data = None
198
+ if method == "PUT":
199
+ try:
200
+ data = canonical_json_utf8(dict(request)).encode("utf-8")
201
+ except (
202
+ TypeError,
203
+ ValueError,
204
+ UnicodeError,
205
+ OverflowError,
206
+ RecursionError,
207
+ ) as error:
208
+ raise GovernedActionClientError(
209
+ "governed action request is invalid"
210
+ ) from error
211
+ if len(data) > EXECUTION_REQUEST_MAX_BYTES:
212
+ raise GovernedActionClientError(
213
+ "governed action request is too large"
214
+ )
215
+ elif method != "GET":
216
+ raise GovernedActionClientError(
217
+ "governed action HTTP method is invalid"
218
+ )
219
+ target = self.origin + _ACTIONS_PATH + urllib.parse.quote(
220
+ action_id, safe="-"
221
+ )
222
+ outbound = urllib.request.Request(
223
+ target,
224
+ data=data,
225
+ method=method,
226
+ headers={
227
+ "Authorization": "Bearer " + self.credential.secret,
228
+ "X-Colony-Principal": self.credential.principal,
229
+ "Accept": "application/json",
230
+ "Content-Type": "application/json",
231
+ },
232
+ )
233
+ try:
234
+ with self.opener.open(outbound, timeout=self.timeout) as response:
235
+ status = getattr(response, "status", None)
236
+ if status is None and hasattr(response, "getcode"):
237
+ status = response.getcode()
238
+ if isinstance(status, bool) or status != 200:
239
+ raise GovernedActionClientError(
240
+ "governed action response was rejected"
241
+ )
242
+ raw = response.read(EXECUTION_RESULT_MAX_BYTES + 1)
243
+ if not isinstance(raw, bytes) or len(raw) > EXECUTION_RESULT_MAX_BYTES:
244
+ raise GovernedActionClientError(
245
+ "governed action response is too large"
246
+ )
247
+ except GovernedActionClientError:
248
+ raise
249
+ except (
250
+ urllib.error.HTTPError,
251
+ urllib.error.URLError,
252
+ TimeoutError,
253
+ OSError,
254
+ ) as error:
255
+ raise GovernedActionClientError(
256
+ "governed action service is unavailable"
257
+ ) from error
258
+ except Exception as error:
259
+ raise GovernedActionClientError(
260
+ "governed action exchange failed"
261
+ ) from error
262
+ value = strict_json_bytes(
263
+ raw,
264
+ maximum=EXECUTION_RESULT_MAX_BYTES,
265
+ error=GovernedActionClientError,
266
+ )
267
+ if not isinstance(value, Mapping):
268
+ raise GovernedActionClientError(
269
+ "governed action response must be an object"
270
+ )
271
+ return value
272
+
273
+ def execute(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
274
+ """Issue THE one mutation PUT for this request. Never retried."""
275
+
276
+ return self._request("PUT", request)
277
+
278
+ def observe(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
279
+ """Issue one side-effect-free reconciliation GET."""
280
+
281
+ return self._request("GET", request)
282
+
283
+
284
+ __all__ = (
285
+ "CREDENTIAL_ID_RE",
286
+ "ClientCredential",
287
+ "GovernedActionClient",
288
+ "GovernedActionClientError",
289
+ "NoRedirectHandler",
290
+ "WORKER_PRINCIPAL",
291
+ "build_no_redirect_opener",
292
+ )
@@ -0,0 +1,53 @@
1
+ """Legacy import names resolve to the canonical module, including its state."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import importlib.abc
6
+ import importlib.util
7
+ import sys
8
+
9
+
10
+ class _AliasLoader(importlib.abc.Loader):
11
+ def __init__(self, canonical, spec):
12
+ self.canonical = canonical
13
+ self.canonical_spec = spec
14
+
15
+ def create_module(self, spec):
16
+ return importlib.import_module(self.canonical)
17
+
18
+ def exec_module(self, module):
19
+ # module_from_spec assigns the alias spec to the returned module.
20
+ # Restore its real identity rather than executing its source again.
21
+ module.__spec__ = self.canonical_spec
22
+ module.__loader__ = self.canonical_spec.loader
23
+
24
+ def get_code(self, fullname):
25
+ # runpy uses get_code for `python -m legacy_package.entrypoint`.
26
+ return self.canonical_spec.loader.get_code(self.canonical)
27
+
28
+ def is_package(self, fullname):
29
+ return self.canonical_spec.submodule_search_locations is not None
30
+
31
+
32
+ class _AliasFinder(importlib.abc.MetaPathFinder):
33
+ def __init__(self, legacy, canonical):
34
+ self.legacy, self.canonical = legacy, canonical
35
+
36
+ def find_spec(self, fullname, path=None, target=None):
37
+ if not fullname.startswith(self.legacy + '.'):
38
+ return None
39
+ canonical = self.canonical + fullname[len(self.legacy):]
40
+ spec = importlib.util.find_spec(canonical)
41
+ if spec is None:
42
+ return None
43
+ loader = _AliasLoader(canonical, spec)
44
+ return importlib.util.spec_from_loader(fullname, loader,
45
+ origin=spec.origin, is_package=spec.submodule_search_locations is not None)
46
+
47
+
48
+ def register_module_alias(legacy: str, canonical: str) -> None:
49
+ """Install a lazy alias once, without eagerly importing optional subsystems."""
50
+ if not any(getattr(finder, 'legacy', None) == legacy
51
+ and getattr(finder, 'canonical', None) == canonical for finder in sys.meta_path):
52
+ sys.meta_path.insert(0, _AliasFinder(legacy, canonical))
53
+ sys.modules[legacy] = importlib.import_module(canonical)
@@ -0,0 +1,52 @@
1
+ """Executable store-adapter conformance suite for governed host workers.
2
+
3
+ Any host must pass this suite with its own :class:`StoreHarness` before
4
+ running its store live::
5
+
6
+ from apsimo_hostworker.conformance import assert_store_conformance
7
+ assert_store_conformance(my_harness_factory)
8
+
9
+ The bundled reference store can be checked from the command line::
10
+
11
+ python -m apsimo_hostworker.conformance
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from .harness import (
17
+ HarnessFactory,
18
+ ManualClock,
19
+ SqliteStoreHarness,
20
+ StoreHarness,
21
+ approval_id,
22
+ build_envelope,
23
+ build_intent,
24
+ delivery_gate_evidence,
25
+ grant_gate_evidence,
26
+ sqlite_harness,
27
+ )
28
+ from .suite import (
29
+ CASES,
30
+ ConformanceFailure,
31
+ ConformanceResult,
32
+ assert_store_conformance,
33
+ run_store_conformance,
34
+ )
35
+
36
+ __all__ = (
37
+ "CASES",
38
+ "ConformanceFailure",
39
+ "ConformanceResult",
40
+ "HarnessFactory",
41
+ "ManualClock",
42
+ "SqliteStoreHarness",
43
+ "StoreHarness",
44
+ "approval_id",
45
+ "assert_store_conformance",
46
+ "build_envelope",
47
+ "build_intent",
48
+ "delivery_gate_evidence",
49
+ "grant_gate_evidence",
50
+ "run_store_conformance",
51
+ "sqlite_harness",
52
+ )
@@ -0,0 +1,29 @@
1
+ """Run the store conformance suite against the bundled reference store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .harness import sqlite_harness
8
+ from .suite import run_store_conformance
9
+
10
+
11
+ def main() -> int:
12
+ results = run_store_conformance(sqlite_harness)
13
+ failures = 0
14
+ for result in results:
15
+ marker = "PASS" if result.passed else "FAIL"
16
+ line = "%s %s" % (marker, result.name)
17
+ if result.detail:
18
+ line += " — " + result.detail
19
+ print(line)
20
+ if not result.passed:
21
+ failures += 1
22
+ print(
23
+ "%d/%d conformance cases passed" % (len(results) - failures, len(results))
24
+ )
25
+ return 1 if failures else 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ sys.exit(main())
@@ -0,0 +1,278 @@
1
+ """Harness contract and fixtures for the store conformance suite.
2
+
3
+ A host proves its :class:`~apsimo_hostworker.store.ActionStore` adapter by
4
+ implementing :class:`StoreHarness` — the store under test plus the two
5
+ ingress operations the suite needs (proposing an action, attaching a gate
6
+ receipt) and a controllable clock — and passing a factory for it to
7
+ :func:`apsimo_hostworker.conformance.run_store_conformance`.
8
+
9
+ The clock MUST be the same clock the store judges time with (invariant
10
+ I11): several cases advance it to prove point-of-use expiry, lease theft,
11
+ and observation deadlines.
12
+
13
+ Everything here is stdlib-only; the suite runs without pytest so it can be
14
+ executed inside a host's own deployment checks
15
+ (``python -m apsimo_hostworker.conformance``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shutil
22
+ import tempfile
23
+ import uuid
24
+ from typing import Any, Callable, Mapping, Protocol, runtime_checkable
25
+
26
+ from ..contract import INTENT_ENVELOPE_SCHEMA
27
+ from ..gate import GRANT_BINDING_METHOD, GRANT_UNLIMITED_SENTINEL
28
+ from ..intent import HermesToolActionIntentV1
29
+ from ..sqlite_store import SqliteActionStore
30
+
31
+
32
+ class ManualClock:
33
+ """Deterministic, explicitly advanced clock for conformance runs."""
34
+
35
+ def __init__(self, start: float = 1_700_000_000.0) -> None:
36
+ self._now = float(start)
37
+
38
+ def now(self) -> float:
39
+ return self._now
40
+
41
+ def advance(self, seconds: float) -> float:
42
+ self._now += float(seconds)
43
+ return self._now
44
+
45
+ def __call__(self) -> float:
46
+ return self._now
47
+
48
+
49
+ @runtime_checkable
50
+ class StoreHarness(Protocol):
51
+ """One store under test plus the ingress the suite drives it with.
52
+
53
+ ``store`` must implement :class:`~apsimo_hostworker.store.ActionStore`.
54
+ ``propose`` and ``add_gate`` are the host's ingress equivalents (however
55
+ they are implemented in production); ``add_gate`` returns
56
+ ``(action, gate_receipt)``. ``now``/``advance`` control the SAME clock
57
+ the store reads. ``close`` releases resources.
58
+ """
59
+
60
+ store: Any
61
+
62
+ def now(self) -> float:
63
+ ...
64
+
65
+ def advance(self, seconds: float) -> float:
66
+ ...
67
+
68
+ def propose(
69
+ self,
70
+ *,
71
+ idempotency_key: str,
72
+ source: str,
73
+ source_ref: str,
74
+ action_type: str,
75
+ payload: Any,
76
+ ) -> Mapping[str, Any]:
77
+ ...
78
+
79
+ def add_gate(
80
+ self,
81
+ action_id: str,
82
+ evidence: Mapping[str, Any],
83
+ *,
84
+ receipt_key: str = "owner-gate",
85
+ external_id: str | None = None,
86
+ ) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
87
+ ...
88
+
89
+ def close(self) -> None:
90
+ ...
91
+
92
+
93
+ HarnessFactory = Callable[[], StoreHarness]
94
+
95
+
96
+ class SqliteStoreHarness:
97
+ """Reference harness: :class:`SqliteActionStore` on a throwaway path.
98
+
99
+ ``store_class`` exists so the suite's own tests can prove the suite
100
+ CATCHES deliberately broken stores; hosts testing a different store
101
+ write their own harness instead.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ directory: str | None = None,
107
+ *,
108
+ store_class: type[SqliteActionStore] = SqliteActionStore,
109
+ ) -> None:
110
+ self._temp = (
111
+ tempfile.mkdtemp(prefix="colony-hostworker-conformance-")
112
+ if directory is None
113
+ else None
114
+ )
115
+ base = self._temp if directory is None else directory
116
+ self.clock = ManualClock()
117
+ self.store = store_class(
118
+ os.path.join(base, "governed-actions.sqlite3"), clock=self.clock
119
+ )
120
+
121
+ def now(self) -> float:
122
+ return self.clock.now()
123
+
124
+ def advance(self, seconds: float) -> float:
125
+ return self.clock.advance(seconds)
126
+
127
+ def propose(self, *, idempotency_key, source, source_ref, action_type, payload):
128
+ return self.store.propose(
129
+ idempotency_key,
130
+ source,
131
+ action_type,
132
+ payload,
133
+ source_ref=source_ref,
134
+ )
135
+
136
+ def add_gate(self, action_id, evidence, *, receipt_key="owner-gate", external_id=None):
137
+ return self.store.gate(
138
+ action_id,
139
+ evidence,
140
+ receipt_key=receipt_key,
141
+ external_id=external_id,
142
+ )
143
+
144
+ def close(self) -> None:
145
+ self.store.close()
146
+ if self._temp:
147
+ shutil.rmtree(self._temp, ignore_errors=True)
148
+
149
+
150
+ def sqlite_harness() -> SqliteStoreHarness:
151
+ """Factory for the reference harness (used by the module runner)."""
152
+
153
+ return SqliteStoreHarness()
154
+
155
+
156
+ # --------------------------------------------------------------- fixtures
157
+
158
+
159
+ def build_intent(
160
+ *,
161
+ tool_name: str = "colony_create_commitment",
162
+ args: Mapping[str, Any] | None = None,
163
+ seed: str | None = None,
164
+ ) -> HermesToolActionIntentV1:
165
+ """One valid governed intent with a unique call identity per ``seed``."""
166
+
167
+ seed = seed or uuid.uuid4().hex[:12]
168
+ if args is None:
169
+ args = {"description": "conformance fixture commitment %s" % seed}
170
+ context = {
171
+ "api_request_id": "req-%s" % seed,
172
+ "authority_lane": "owner",
173
+ "contact_id": "contact-conformance",
174
+ "platform": "conformance",
175
+ "sender_id": "owner:conformance",
176
+ "session_id": "sess-%s" % seed,
177
+ "task_id": "",
178
+ "tool_call_id": "call-%s" % seed,
179
+ "turn_id": "turn-%s" % seed,
180
+ }
181
+ return HermesToolActionIntentV1.build(
182
+ tool_name=tool_name, args=args, context=context
183
+ )
184
+
185
+
186
+ def build_envelope(intent: HermesToolActionIntentV1) -> dict[str, Any]:
187
+ return {
188
+ "schema": INTENT_ENVELOPE_SCHEMA,
189
+ "version": 1,
190
+ "intent": intent.to_dict(),
191
+ }
192
+
193
+
194
+ def approval_id(seed: str | None = None) -> str:
195
+ return "APR-" + (seed or uuid.uuid4().hex[:12]).upper()[:12].rjust(12, "0")
196
+
197
+
198
+ def delivery_gate_evidence(
199
+ action: Mapping[str, Any],
200
+ *,
201
+ decided_at: float,
202
+ expires_at: float,
203
+ approval: str | None = None,
204
+ decision_id: str | None = None,
205
+ action_digest: str | None = None,
206
+ ) -> dict[str, Any]:
207
+ """Message-delivery-shaped owner approval evidence bound to ``action``."""
208
+
209
+ seed = uuid.uuid4().hex[:8]
210
+ return {
211
+ "decision": "approved",
212
+ "authority": "owner",
213
+ "decision_id": decision_id or ("decision-%s" % seed),
214
+ "approval_id": approval or approval_id(),
215
+ "action_id": action["action_id"],
216
+ "action_digest": (
217
+ action["payload_sha256"] if action_digest is None else action_digest
218
+ ),
219
+ "revision": 1,
220
+ "principal": "owner:conformance",
221
+ "channel": "conformance-channel",
222
+ "thread_id": "thread-%s" % seed,
223
+ "event_id": "event-%s" % seed,
224
+ "event_key": "event-key-%s" % seed,
225
+ "delivery_id": "delivery-%s" % seed,
226
+ "delivery_message_id": "message-%s" % seed,
227
+ "binding_method": "delivered_reply",
228
+ "decided_at_epoch": float(decided_at),
229
+ "expires_at_epoch": float(expires_at),
230
+ }
231
+
232
+
233
+ def grant_gate_evidence(
234
+ action: Mapping[str, Any],
235
+ *,
236
+ decided_at: float,
237
+ expires_at: float,
238
+ grant_expires_at: float | str,
239
+ approval: str | None = None,
240
+ decision_id: str | None = None,
241
+ ) -> dict[str, Any]:
242
+ """Bounded-grant-shaped owner approval evidence bound to ``action``."""
243
+
244
+ seed = uuid.uuid4().hex[:8]
245
+ return {
246
+ "decision": "approved",
247
+ "authority": "owner",
248
+ "decision_id": decision_id or ("decision-%s" % seed),
249
+ "approval_id": approval or approval_id(),
250
+ "action_id": action["action_id"],
251
+ "action_digest": action["payload_sha256"],
252
+ "revision": 1,
253
+ "principal": "owner:conformance",
254
+ "bounded_grant_id": "grant-%s" % seed,
255
+ "approval_source_request_id": "request-%s" % seed,
256
+ "bounded_grant_expires_at_epoch": (
257
+ GRANT_UNLIMITED_SENTINEL
258
+ if grant_expires_at == GRANT_UNLIMITED_SENTINEL
259
+ else float(grant_expires_at)
260
+ ),
261
+ "binding_method": GRANT_BINDING_METHOD,
262
+ "decided_at_epoch": float(decided_at),
263
+ "expires_at_epoch": float(expires_at),
264
+ }
265
+
266
+
267
+ __all__ = (
268
+ "HarnessFactory",
269
+ "ManualClock",
270
+ "SqliteStoreHarness",
271
+ "StoreHarness",
272
+ "approval_id",
273
+ "build_envelope",
274
+ "build_intent",
275
+ "delivery_gate_evidence",
276
+ "grant_gate_evidence",
277
+ "sqlite_harness",
278
+ )