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,402 @@
1
+ """Stateless wire contract for governed Colony host-worker actions.
2
+
3
+ This module is the single written-down form of the governed-action wire
4
+ contract: schema names, field sets, identifier grammars, size bounds, and the
5
+ two canonical-JSON digest conventions. Everything here is stdlib-only and
6
+ free of I/O so that any process — the private host worker, tooling, or tests —
7
+ can validate the contract without importing a server.
8
+
9
+ CRITICAL DESIGN RULE — INDEPENDENT VALIDATORS, DO NOT "UNIFY"
10
+ =============================================================
11
+ ColonyAI's endpoint (``sidecar/apsimo/governed_actions.py``) MUST KEEP
12
+ ITS OWN INDEPENDENT VALIDATOR. Do NOT refactor the endpoint to import
13
+ ``apsimo_hostworker``. The two implementations are deliberately separate and
14
+ cross-check each other; that redundancy has already caught a real
15
+ incompatibility (the ASCII/UTF-8 canonical-JSON digest split documented
16
+ below). A repo-internal test (``sidecar/tests/test_hostworker_agreement.py``)
17
+ runs BOTH implementations against shared golden vectors and fails if they
18
+ disagree. Anyone later "cleaning this up" by import-unifying them turns this
19
+ from a safety improvement into a safety loss: a single shared bug would then
20
+ validate itself on both sides of the trust boundary.
21
+
22
+ THE TWO CANONICAL-JSON CONVENTIONS
23
+ ==================================
24
+ There are — deliberately and permanently — two canonical JSON serializations
25
+ on this wire, differing only in ``ensure_ascii``:
26
+
27
+ * ASCII-escaped (``canonical_json_ascii`` / ``sha256_json_ascii``): the
28
+ historical convention introduced by the Hermes plugin. It computes
29
+ ``args_sha256``, ``context_sha256``, ``idempotency_key``, ``intent_digest``,
30
+ and the ``intent_id`` derivation of ``HermesToolActionIntentV1``.
31
+
32
+ * UTF-8 (``canonical_json_utf8`` / ``sha256_json_utf8``): the host Action
33
+ Plane convention. It computes the outer ``execution_digest`` of
34
+ ``ColonyGovernedActionExecutionV1``, the returned ``effect_digest``, the
35
+ action ``payload_sha256`` (a.k.a. ``action_digest``), and every receipt
36
+ ``evidence_sha256``.
37
+
38
+ The split exists because two codebases grew the two digests independently and
39
+ both are now pinned by durable ledgers and immutable receipts on both sides.
40
+ Re-serializing either family with the other convention changes every digest
41
+ on the wire. The conventions are therefore named separately, pinned by golden
42
+ vectors, and must never be merged or "fixed".
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import hashlib
48
+ import json
49
+ import math
50
+ import re
51
+ from typing import Any, Mapping
52
+
53
+
54
+ class GovernedContractError(ValueError):
55
+ """A value is not the exact bounded governed-action contract."""
56
+
57
+
58
+ # --------------------------------------------------------------------------
59
+ # Schema names (public wire strings; never rename)
60
+ # --------------------------------------------------------------------------
61
+
62
+ INTENT_SCHEMA = "HermesToolActionIntentV1"
63
+ INTENT_ENVELOPE_SCHEMA = "HermesToolActionEnvelopeV1"
64
+ CALL_IDENTITY_SCHEMA = "HermesActionCallV1"
65
+ EXECUTION_REQUEST_SCHEMA = "ColonyGovernedActionExecutionV1"
66
+ APPROVAL_BINDING_SCHEMA = "ColonyOwnerApprovalExecutionBindingV1"
67
+ EXECUTION_RESULT_SCHEMA = "ColonyGovernedActionExecutionResultV1"
68
+ EFFECT_SCHEMA = "ColonyGovernedActionEffectV1"
69
+
70
+ # --------------------------------------------------------------------------
71
+ # Exact field sets
72
+ # --------------------------------------------------------------------------
73
+
74
+ INTENT_FIELDS = frozenset(
75
+ {
76
+ "schema",
77
+ "version",
78
+ "intent_id",
79
+ "idempotency_key",
80
+ "tool_name",
81
+ "args",
82
+ "args_sha256",
83
+ "context",
84
+ "context_sha256",
85
+ "intent_digest",
86
+ }
87
+ )
88
+ CONTEXT_FIELDS = frozenset(
89
+ {
90
+ "api_request_id",
91
+ "authority_lane",
92
+ "contact_id",
93
+ "platform",
94
+ "sender_id",
95
+ "session_id",
96
+ "task_id",
97
+ "tool_call_id",
98
+ "turn_id",
99
+ }
100
+ )
101
+ EXECUTION_REQUEST_FIELDS = frozenset(
102
+ {
103
+ "schema",
104
+ "version",
105
+ "action_id",
106
+ "action_digest",
107
+ "intent_id",
108
+ "intent_digest",
109
+ "tool_name",
110
+ "args",
111
+ "args_sha256",
112
+ "approval",
113
+ "execution_digest",
114
+ }
115
+ )
116
+ APPROVAL_BINDING_FIELDS = frozenset(
117
+ {
118
+ "schema",
119
+ "version",
120
+ "approval_id",
121
+ "decision_id",
122
+ "revision",
123
+ "authorization_receipt_sha256",
124
+ "decided_at",
125
+ "expires_at",
126
+ }
127
+ )
128
+ EXECUTION_RESULT_FIELDS = frozenset(
129
+ {
130
+ "schema",
131
+ "version",
132
+ "execution_digest",
133
+ "action_id",
134
+ "action_digest",
135
+ "intent_id",
136
+ "intent_digest",
137
+ "tool_name",
138
+ "status",
139
+ "effect_state",
140
+ "effect",
141
+ "effect_digest",
142
+ "observed_at",
143
+ }
144
+ )
145
+ EFFECT_FIELDS = frozenset(
146
+ {"schema", "version", "effect_id", "outcome", "verification"}
147
+ )
148
+
149
+ # --------------------------------------------------------------------------
150
+ # Identifier grammars
151
+ # --------------------------------------------------------------------------
152
+
153
+ SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
154
+ INTENT_ID_RE = re.compile(r"^hti_[0-9a-f]{32}$")
155
+ # The approval-id form ColonyAI's endpoint already enforces on the wire.
156
+ APPROVAL_ID_RE = re.compile(r"^APR-[A-Z0-9]{12}$")
157
+ SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$")
158
+ # ``(?![\s\S])`` is an exact end-of-string assertion in both Python and the
159
+ # ECMAScript regex dialect used by JSON Schema. A terminal ``$`` is not exact
160
+ # there: it may also match immediately before a final newline.
161
+ IDENTIFIER_RE = re.compile(
162
+ r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}(?![\s\S])"
163
+ )
164
+ ACTION_ID_RE = re.compile(
165
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
166
+ )
167
+
168
+ # --------------------------------------------------------------------------
169
+ # Size and time bounds
170
+ # --------------------------------------------------------------------------
171
+
172
+ EXECUTION_REQUEST_MAX_BYTES = 32 * 1024
173
+ EXECUTION_RESULT_MAX_BYTES = 16 * 1024
174
+ EFFECT_MAX_BYTES = 8 * 1024
175
+ RESEARCH_TOPIC_MAX_CHARS = 1400
176
+ IDENTIFIER_MAX_CHARS = 256
177
+ BOUNDED_JSON_MAX_NODES = 512
178
+ BOUNDED_JSON_MAX_DEPTH = 8
179
+ BOUNDED_JSON_STRING_MAX_CHARS = 4096
180
+ BOUNDED_JSON_KEY_MAX_CHARS = 128
181
+ BOUNDED_JSON_INTEGER_MAX = (1 << 63) - 1
182
+ APPROVAL_MAX_LIFETIME_SECONDS = 86_400
183
+ # The one skew allowance shared by every "decided in the future?" sanity
184
+ # check on both sides of the boundary today.
185
+ GATE_CLOCK_SKEW_SECONDS = 30.0
186
+
187
+ # --------------------------------------------------------------------------
188
+ # Canonical JSON — both conventions, separately named on purpose
189
+ # --------------------------------------------------------------------------
190
+
191
+
192
+ def canonical_json_ascii(value: Any) -> str:
193
+ """Historical ASCII-escaped canonical JSON.
194
+
195
+ Digest family: ``args_sha256``, ``context_sha256``, ``idempotency_key``,
196
+ ``intent_digest`` (and the ``intent_id`` derived from the idempotency
197
+ key). See the module docstring for why this must never be merged with
198
+ :func:`canonical_json_utf8`.
199
+ """
200
+
201
+ try:
202
+ return json.dumps(
203
+ value,
204
+ sort_keys=True,
205
+ separators=(",", ":"),
206
+ ensure_ascii=True,
207
+ allow_nan=False,
208
+ )
209
+ except (TypeError, ValueError, OverflowError, RecursionError) as error:
210
+ raise GovernedContractError("value is not canonical JSON") from error
211
+
212
+
213
+ def canonical_json_utf8(value: Any) -> str:
214
+ """Host Action Plane canonical UTF-8 JSON.
215
+
216
+ Digest family: ``execution_digest``, ``effect_digest``,
217
+ ``payload_sha256``/``action_digest``, and receipt ``evidence_sha256``.
218
+ See the module docstring for why this must never be merged with
219
+ :func:`canonical_json_ascii`.
220
+ """
221
+
222
+ try:
223
+ return json.dumps(
224
+ value,
225
+ sort_keys=True,
226
+ separators=(",", ":"),
227
+ ensure_ascii=False,
228
+ allow_nan=False,
229
+ )
230
+ except (TypeError, ValueError, OverflowError, RecursionError) as error:
231
+ raise GovernedContractError("value is not canonical JSON") from error
232
+
233
+
234
+ def sha256_json_ascii(value: Any) -> str:
235
+ return hashlib.sha256(canonical_json_ascii(value).encode("utf-8")).hexdigest()
236
+
237
+
238
+ def sha256_json_utf8(value: Any) -> str:
239
+ return hashlib.sha256(canonical_json_utf8(value).encode("utf-8")).hexdigest()
240
+
241
+
242
+ # --------------------------------------------------------------------------
243
+ # Shared bounded-scalar validators (used by the catalog and the intent)
244
+ # --------------------------------------------------------------------------
245
+
246
+
247
+ def exact_mapping(
248
+ value: Any,
249
+ name: str,
250
+ *,
251
+ allowed: frozenset[str] | set[str],
252
+ required=(),
253
+ ) -> dict[str, Any]:
254
+ """Return ``dict(value)`` iff keys are strings within/covering the bounds."""
255
+
256
+ if not isinstance(value, Mapping):
257
+ raise GovernedContractError("%s must be an object" % name)
258
+ keys = set(value)
259
+ if any(not isinstance(key, str) for key in keys):
260
+ raise GovernedContractError("%s keys must be strings" % name)
261
+ if keys - set(allowed) or set(required) - keys:
262
+ raise GovernedContractError("%s fields are invalid" % name)
263
+ return dict(value)
264
+
265
+
266
+ def bounded_text(
267
+ value: Any,
268
+ name: str,
269
+ maximum: int,
270
+ *,
271
+ allow_empty: bool = False,
272
+ identifier: bool = False,
273
+ ) -> str:
274
+ if not isinstance(value, str) or len(value) > maximum:
275
+ raise GovernedContractError("%s must be a bounded string" % name)
276
+ if any(
277
+ ord(character) == 0 or 0xD800 <= ord(character) <= 0xDFFF
278
+ for character in value
279
+ ):
280
+ raise GovernedContractError("%s contains invalid characters" % name)
281
+ if not allow_empty and not value.strip():
282
+ raise GovernedContractError("%s cannot be empty" % name)
283
+ if identifier and not IDENTIFIER_RE.fullmatch(value):
284
+ raise GovernedContractError("%s is not a canonical identifier" % name)
285
+ return value
286
+
287
+
288
+ def bounded_integer(value: Any, name: str, minimum: int, maximum: int) -> int:
289
+ if isinstance(value, bool) or not isinstance(value, int):
290
+ raise GovernedContractError("%s must be an integer" % name)
291
+ if value < minimum or value > maximum:
292
+ raise GovernedContractError("%s is outside its allowed range" % name)
293
+ return value
294
+
295
+
296
+ def bounded_number(
297
+ value: Any, name: str, minimum: float, maximum: float
298
+ ) -> int | float:
299
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
300
+ raise GovernedContractError("%s must be a number" % name)
301
+ try:
302
+ finite = math.isfinite(float(value))
303
+ except (OverflowError, ValueError):
304
+ finite = False
305
+ if not finite or value < minimum or value > maximum:
306
+ raise GovernedContractError("%s is outside its allowed range" % name)
307
+ return value
308
+
309
+
310
+ def enum_text(value: Any, name: str, allowed: frozenset[str]) -> str:
311
+ if not isinstance(value, str) or value not in allowed:
312
+ raise GovernedContractError("%s is not an allowed value" % name)
313
+ return value
314
+
315
+
316
+ def bounded_json_value(
317
+ value: Any, name: str, *, depth: int = 0, counter=None
318
+ ) -> Any:
319
+ """Validate one small JSON value without coercion or exotic numerics."""
320
+
321
+ if counter is None:
322
+ counter = [0]
323
+ counter[0] += 1
324
+ if counter[0] > BOUNDED_JSON_MAX_NODES or depth > BOUNDED_JSON_MAX_DEPTH:
325
+ raise GovernedContractError("%s is too complex" % name)
326
+ if value is None or isinstance(value, bool):
327
+ return value
328
+ if isinstance(value, int):
329
+ if abs(value) > BOUNDED_JSON_INTEGER_MAX:
330
+ raise GovernedContractError("%s integer is too large" % name)
331
+ return value
332
+ if isinstance(value, float):
333
+ if not math.isfinite(value):
334
+ raise GovernedContractError("%s number must be finite" % name)
335
+ return value
336
+ if isinstance(value, str):
337
+ return bounded_text(
338
+ value, name, BOUNDED_JSON_STRING_MAX_CHARS, allow_empty=True,
339
+ )
340
+ if isinstance(value, list):
341
+ return [
342
+ bounded_json_value(item, name, depth=depth + 1, counter=counter)
343
+ for item in value
344
+ ]
345
+ if isinstance(value, Mapping):
346
+ result = {}
347
+ for key, item in value.items():
348
+ key = bounded_text(
349
+ key, "%s key" % name, BOUNDED_JSON_KEY_MAX_CHARS,
350
+ identifier=True,
351
+ )
352
+ result[key] = bounded_json_value(
353
+ item, name, depth=depth + 1, counter=counter
354
+ )
355
+ return result
356
+ raise GovernedContractError("%s contains a non-JSON value" % name)
357
+
358
+
359
+ __all__ = (
360
+ "ACTION_ID_RE",
361
+ "APPROVAL_BINDING_FIELDS",
362
+ "APPROVAL_BINDING_SCHEMA",
363
+ "APPROVAL_ID_RE",
364
+ "APPROVAL_MAX_LIFETIME_SECONDS",
365
+ "BOUNDED_JSON_INTEGER_MAX",
366
+ "BOUNDED_JSON_KEY_MAX_CHARS",
367
+ "BOUNDED_JSON_MAX_DEPTH",
368
+ "BOUNDED_JSON_MAX_NODES",
369
+ "BOUNDED_JSON_STRING_MAX_CHARS",
370
+ "CALL_IDENTITY_SCHEMA",
371
+ "CONTEXT_FIELDS",
372
+ "EFFECT_FIELDS",
373
+ "EFFECT_MAX_BYTES",
374
+ "EFFECT_SCHEMA",
375
+ "EXECUTION_REQUEST_FIELDS",
376
+ "EXECUTION_REQUEST_MAX_BYTES",
377
+ "EXECUTION_REQUEST_SCHEMA",
378
+ "EXECUTION_RESULT_FIELDS",
379
+ "EXECUTION_RESULT_MAX_BYTES",
380
+ "EXECUTION_RESULT_SCHEMA",
381
+ "GATE_CLOCK_SKEW_SECONDS",
382
+ "GovernedContractError",
383
+ "IDENTIFIER_MAX_CHARS",
384
+ "IDENTIFIER_RE",
385
+ "INTENT_ENVELOPE_SCHEMA",
386
+ "INTENT_FIELDS",
387
+ "INTENT_ID_RE",
388
+ "INTENT_SCHEMA",
389
+ "RESEARCH_TOPIC_MAX_CHARS",
390
+ "SAFE_ID_RE",
391
+ "SHA256_RE",
392
+ "bounded_integer",
393
+ "bounded_json_value",
394
+ "bounded_number",
395
+ "bounded_text",
396
+ "canonical_json_ascii",
397
+ "canonical_json_utf8",
398
+ "enum_text",
399
+ "exact_mapping",
400
+ "sha256_json_ascii",
401
+ "sha256_json_utf8",
402
+ )