constellation-node-sdk 1.0.1__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 (45) hide show
  1. constellation_node_sdk/__init__.py +123 -0
  2. constellation_node_sdk/gate/__init__.py +47 -0
  3. constellation_node_sdk/gate/client.py +108 -0
  4. constellation_node_sdk/gate/config.py +153 -0
  5. constellation_node_sdk/gate/errors.py +17 -0
  6. constellation_node_sdk/gate/policy.py +70 -0
  7. constellation_node_sdk/gate/registration.py +144 -0
  8. constellation_node_sdk/orchestrator/__init__.py +3 -0
  9. constellation_node_sdk/orchestrator/base.py +57 -0
  10. constellation_node_sdk/orchestrator/merge.py +47 -0
  11. constellation_node_sdk/orchestrator/packet_builder.py +49 -0
  12. constellation_node_sdk/orchestrator/retry.py +61 -0
  13. constellation_node_sdk/orchestrator/state.py +78 -0
  14. constellation_node_sdk/orchestrator/step_executor.py +87 -0
  15. constellation_node_sdk/py.typed +0 -0
  16. constellation_node_sdk/runtime/__init__.py +52 -0
  17. constellation_node_sdk/runtime/app.py +274 -0
  18. constellation_node_sdk/runtime/config.py +307 -0
  19. constellation_node_sdk/runtime/errors.py +112 -0
  20. constellation_node_sdk/runtime/execution.py +249 -0
  21. constellation_node_sdk/runtime/handlers.py +45 -0
  22. constellation_node_sdk/runtime/inbound_policy.py +86 -0
  23. constellation_node_sdk/runtime/lifecycle.py +29 -0
  24. constellation_node_sdk/runtime/observability.py +72 -0
  25. constellation_node_sdk/runtime/preflight.py +122 -0
  26. constellation_node_sdk/security/__init__.py +36 -0
  27. constellation_node_sdk/security/delegation.py +57 -0
  28. constellation_node_sdk/security/errors.py +25 -0
  29. constellation_node_sdk/security/signing.py +121 -0
  30. constellation_node_sdk/security/validation.py +230 -0
  31. constellation_node_sdk/security/verification.py +107 -0
  32. constellation_node_sdk/transport/__init__.py +88 -0
  33. constellation_node_sdk/transport/codec.py +21 -0
  34. constellation_node_sdk/transport/errors.py +41 -0
  35. constellation_node_sdk/transport/hashing.py +73 -0
  36. constellation_node_sdk/transport/hop_trace.py +378 -0
  37. constellation_node_sdk/transport/lineage.py +28 -0
  38. constellation_node_sdk/transport/models.py +447 -0
  39. constellation_node_sdk/transport/packet.py +331 -0
  40. constellation_node_sdk/transport/provenance.py +53 -0
  41. constellation_node_sdk/transport/tenant.py +76 -0
  42. constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
  43. constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
  44. constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
  45. constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+ from uuid import UUID, uuid4
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
9
+
10
+ _ALLOWED_PACKET_TYPES = {
11
+ "request",
12
+ "response",
13
+ "event",
14
+ "command",
15
+ "delegation",
16
+ "failure",
17
+ "replay_request",
18
+ "replay_response",
19
+ "compensation",
20
+ }
21
+ _ALLOWED_CLASSIFICATIONS = {"public", "internal", "confidential", "restricted"}
22
+ _ALLOWED_ENCRYPTION = {"plaintext", "encrypted", "envelope_only"}
23
+ _ALLOWED_SIGNATURE_ALGORITHMS = {"hmac-sha256", "ed25519"}
24
+ _ALLOWED_HOP_DIRECTIONS = {"ingress", "dispatch", "execution", "response"}
25
+ _ALLOWED_HOP_STATUSES = {"received", "validated", "processing", "delegated", "completed", "failed"}
26
+ _ACTION_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
27
+
28
+
29
+ def utc_now() -> datetime:
30
+ """Return the current UTC time as a timezone-aware datetime."""
31
+ return datetime.now(UTC)
32
+
33
+
34
+ def ensure_utc(value: datetime | None) -> datetime | None:
35
+ """Normalize a datetime to UTC."""
36
+ if value is None:
37
+ return None
38
+ if value.tzinfo is None:
39
+ return value.replace(tzinfo=UTC)
40
+ return value.astimezone(UTC)
41
+
42
+
43
+ def normalize_optional_string(value: str | None) -> str | None:
44
+ """Normalize an optional string, forbidding blank-but-present values."""
45
+ if value is None:
46
+ return None
47
+ normalized = value.strip()
48
+ if not normalized:
49
+ raise ValueError("optional string fields must not be blank")
50
+ return normalized
51
+
52
+
53
+ def validate_sha256_hex(value: str, *, field_name: str) -> str:
54
+ """Validate that a value is a 64-character lowercase SHA-256 hex digest."""
55
+ normalized = value.strip().lower()
56
+ if len(normalized) != 64 or any(ch not in "0123456789abcdef" for ch in normalized):
57
+ raise ValueError(f"{field_name} must be a 64-char lowercase sha256 hex digest")
58
+ return normalized
59
+
60
+
61
+ class TransportHeader(BaseModel):
62
+ model_config = ConfigDict(frozen=True, extra="forbid")
63
+
64
+ packet_id: UUID = Field(default_factory=uuid4)
65
+ packet_type: str
66
+ action: str
67
+ priority: int = 2
68
+ created_at: datetime = Field(default_factory=utc_now)
69
+ expires_at: datetime | None = None
70
+ timeout_ms: int = Field(default=30_000, ge=1)
71
+
72
+ schema_version: str = "1.0"
73
+ idempotency_key: str | None = None
74
+ trace_id: str | None = None
75
+ correlation_id: str | None = None
76
+ causation_id: UUID | None = None
77
+ retry_count: int = Field(default=0, ge=0)
78
+ replay_mode: bool = False
79
+ not_before: datetime | None = None
80
+
81
+ @field_validator("packet_type")
82
+ @classmethod
83
+ def validate_packet_type(cls, value: str) -> str:
84
+ normalized = value.strip().lower()
85
+ if normalized not in _ALLOWED_PACKET_TYPES:
86
+ msg = f"packet_type must be one of {sorted(_ALLOWED_PACKET_TYPES)}"
87
+ raise ValueError(msg)
88
+ return normalized
89
+
90
+ @field_validator("action")
91
+ @classmethod
92
+ def validate_action(cls, value: str) -> str:
93
+ normalized = value.strip().lower()
94
+ if not _ACTION_RE.match(normalized):
95
+ raise ValueError(
96
+ "action must be lowercase alphanumeric + dash/underscore/dot, max 64 chars"
97
+ )
98
+ return normalized
99
+
100
+ @field_validator("priority", mode="before")
101
+ @classmethod
102
+ def coerce_priority(cls, value: Any) -> int:
103
+ if isinstance(value, str):
104
+ rendered = value.strip().upper()
105
+ if rendered in {"P0", "P1", "P2", "P3"}:
106
+ return int(rendered[-1])
107
+ return int(rendered)
108
+ return int(value)
109
+
110
+ @field_validator("created_at", "expires_at", "not_before")
111
+ @classmethod
112
+ def normalize_datetimes(cls, value: datetime | None) -> datetime | None:
113
+ return ensure_utc(value)
114
+
115
+ @field_validator("idempotency_key", "trace_id", "correlation_id", "schema_version")
116
+ @classmethod
117
+ def validate_optional_strings(cls, value: str | None) -> str | None:
118
+ return normalize_optional_string(value)
119
+
120
+ @model_validator(mode="after")
121
+ def validate_header(self) -> TransportHeader:
122
+ if not 0 <= self.priority <= 3:
123
+ raise ValueError("priority must be between 0 and 3")
124
+ if self.expires_at is not None and self.expires_at <= self.created_at:
125
+ raise ValueError("expires_at must be later than created_at")
126
+ if self.not_before is not None and self.not_before < self.created_at:
127
+ raise ValueError("not_before must not be earlier than created_at")
128
+ return self
129
+
130
+
131
+ class TransportAddress(BaseModel):
132
+ model_config = ConfigDict(frozen=True, extra="forbid")
133
+
134
+ source_node: str
135
+ destination_node: str
136
+ reply_to: str
137
+
138
+ @field_validator("source_node", "destination_node", "reply_to")
139
+ @classmethod
140
+ def validate_node_name(cls, value: str) -> str:
141
+ normalized = value.strip().lower()
142
+ if not normalized:
143
+ raise ValueError("address fields must not be empty")
144
+ return normalized
145
+
146
+
147
+ class TransportSecurity(BaseModel):
148
+ model_config = ConfigDict(frozen=True, extra="forbid")
149
+
150
+ payload_hash: str
151
+ transport_hash: str
152
+ signature: str | None = None
153
+ signature_algorithm: str | None = None
154
+ signing_key_id: str | None = None
155
+ classification: str = "internal"
156
+ encryption_status: str = "plaintext"
157
+ pii_fields: tuple[str, ...] = ()
158
+
159
+ @field_validator("payload_hash", "transport_hash")
160
+ @classmethod
161
+ def validate_hashes(cls, value: str) -> str:
162
+ return validate_sha256_hex(value, field_name="hash field")
163
+
164
+ @field_validator("signature", "signing_key_id")
165
+ @classmethod
166
+ def validate_optional_strings(cls, value: str | None) -> str | None:
167
+ return normalize_optional_string(value)
168
+
169
+ @field_validator("signature_algorithm")
170
+ @classmethod
171
+ def validate_signature_algorithm(cls, value: str | None) -> str | None:
172
+ if value is None:
173
+ return None
174
+ normalized = value.strip().lower()
175
+ if normalized not in _ALLOWED_SIGNATURE_ALGORITHMS:
176
+ msg = f"signature_algorithm must be one of {sorted(_ALLOWED_SIGNATURE_ALGORITHMS)}"
177
+ raise ValueError(msg)
178
+ return normalized
179
+
180
+ @field_validator("classification")
181
+ @classmethod
182
+ def validate_classification(cls, value: str) -> str:
183
+ normalized = value.strip().lower()
184
+ if normalized not in _ALLOWED_CLASSIFICATIONS:
185
+ msg = f"classification must be one of {sorted(_ALLOWED_CLASSIFICATIONS)}"
186
+ raise ValueError(msg)
187
+ return normalized
188
+
189
+ @field_validator("encryption_status")
190
+ @classmethod
191
+ def validate_encryption_status(cls, value: str) -> str:
192
+ normalized = value.strip().lower()
193
+ if normalized not in _ALLOWED_ENCRYPTION:
194
+ msg = f"encryption_status must be one of {sorted(_ALLOWED_ENCRYPTION)}"
195
+ raise ValueError(msg)
196
+ return normalized
197
+
198
+ @field_validator("pii_fields", mode="before")
199
+ @classmethod
200
+ def coerce_pii_fields(cls, value: Any) -> Any:
201
+ if isinstance(value, (list, tuple)):
202
+ return tuple(value)
203
+ return value
204
+
205
+ @field_validator("pii_fields")
206
+ @classmethod
207
+ def validate_pii_fields(cls, value: tuple[str, ...]) -> tuple[str, ...]:
208
+ normalized = tuple(path.strip() for path in value if path.strip())
209
+ if len(set(normalized)) != len(normalized):
210
+ raise ValueError("pii_fields must not contain duplicates")
211
+ return normalized
212
+
213
+ @model_validator(mode="after")
214
+ def validate_signature_state(self) -> TransportSecurity:
215
+ if self.signature is not None and self.signature_algorithm is None:
216
+ raise ValueError("signature_algorithm is required when signature is present")
217
+ if self.signature_algorithm is not None and self.signature is None:
218
+ raise ValueError("signature is required when signature_algorithm is present")
219
+ return self
220
+
221
+
222
+ class TransportGovernance(BaseModel):
223
+ model_config = ConfigDict(frozen=True, extra="forbid")
224
+
225
+ intent: str
226
+ compliance_tags: tuple[str, ...] = ()
227
+ retention_days: int = Field(default=90, ge=0)
228
+ redaction_applied: bool = False
229
+ audit_required: bool = False
230
+ data_subject_id: str | None = None
231
+
232
+ @field_validator("intent")
233
+ @classmethod
234
+ def validate_intent(cls, value: str) -> str:
235
+ normalized = value.strip().lower()
236
+ if not normalized:
237
+ raise ValueError("intent must not be empty")
238
+ return normalized
239
+
240
+ @field_validator("compliance_tags", mode="before")
241
+ @classmethod
242
+ def coerce_compliance_tags(cls, value: Any) -> Any:
243
+ if isinstance(value, (list, tuple)):
244
+ return tuple(value)
245
+ return value
246
+
247
+ @field_validator("compliance_tags")
248
+ @classmethod
249
+ def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
250
+ normalized = tuple(tag.strip().upper() for tag in value if tag.strip())
251
+ if len(set(normalized)) != len(normalized):
252
+ raise ValueError("compliance_tags must not contain duplicates")
253
+ return normalized
254
+
255
+ @field_validator("data_subject_id")
256
+ @classmethod
257
+ def validate_data_subject_id(cls, value: str | None) -> str | None:
258
+ return normalize_optional_string(value)
259
+
260
+
261
+ class TransportLineage(BaseModel):
262
+ model_config = ConfigDict(frozen=True, extra="forbid")
263
+
264
+ parent_id: UUID | None = None
265
+ root_id: UUID
266
+ generation: int = Field(default=0, ge=0)
267
+
268
+
269
+ class TransportAttachment(BaseModel):
270
+ model_config = ConfigDict(frozen=True, extra="forbid")
271
+
272
+ attachment_id: UUID = Field(default_factory=uuid4)
273
+ media_type: str
274
+ uri: str
275
+ content_hash: str
276
+ encrypted: bool = True
277
+ size_bytes: int = Field(ge=0)
278
+
279
+ @field_validator("media_type", "uri")
280
+ @classmethod
281
+ def validate_required_strings(cls, value: str) -> str:
282
+ normalized = value.strip()
283
+ if not normalized:
284
+ raise ValueError("attachment fields must not be empty")
285
+ return normalized
286
+
287
+ @field_validator("content_hash")
288
+ @classmethod
289
+ def validate_content_hash(cls, value: str) -> str:
290
+ return validate_sha256_hex(value, field_name="attachment content_hash")
291
+
292
+
293
+ class TransportHop(BaseModel):
294
+ model_config = ConfigDict(frozen=True, extra="forbid")
295
+
296
+ hop_id: UUID = Field(default_factory=uuid4)
297
+ packet_id: UUID
298
+ node: str
299
+ action: str
300
+ direction: str
301
+ status: str
302
+ timestamp: datetime = Field(default_factory=utc_now)
303
+
304
+ attempt: int | None = Field(default=None, ge=1)
305
+ target_node: str | None = None
306
+ duration_ms: int | None = Field(default=None, ge=0)
307
+ queue_ms: int | None = Field(default=None, ge=0)
308
+ network_ms: int | None = Field(default=None, ge=0)
309
+ error_code: str | None = None
310
+ error_message: str | None = None
311
+
312
+ previous_hop_hash: str | None = None
313
+ hop_hash: str
314
+ hop_signature: str | None = None
315
+ hop_signature_algorithm: str | None = None
316
+ hop_signing_key_id: str | None = None
317
+
318
+ @field_validator("node", "action")
319
+ @classmethod
320
+ def validate_required_strings(cls, value: str) -> str:
321
+ normalized = value.strip().lower()
322
+ if not normalized:
323
+ raise ValueError("hop fields must not be empty")
324
+ return normalized
325
+
326
+ @field_validator("direction")
327
+ @classmethod
328
+ def validate_direction(cls, value: str) -> str:
329
+ normalized = value.strip().lower()
330
+ if normalized not in _ALLOWED_HOP_DIRECTIONS:
331
+ msg = f"direction must be one of {sorted(_ALLOWED_HOP_DIRECTIONS)}"
332
+ raise ValueError(msg)
333
+ return normalized
334
+
335
+ @field_validator("status")
336
+ @classmethod
337
+ def validate_status(cls, value: str) -> str:
338
+ normalized = value.strip().lower()
339
+ if normalized not in _ALLOWED_HOP_STATUSES:
340
+ msg = f"status must be one of {sorted(_ALLOWED_HOP_STATUSES)}"
341
+ raise ValueError(msg)
342
+ return normalized
343
+
344
+ @field_validator("timestamp")
345
+ @classmethod
346
+ def normalize_timestamp(cls, value: datetime) -> datetime:
347
+ normalized = ensure_utc(value)
348
+ if normalized is None:
349
+ raise ValueError("timestamp must not be null")
350
+ return normalized
351
+
352
+ @field_validator(
353
+ "target_node", "error_code", "error_message", "hop_signature", "hop_signing_key_id"
354
+ )
355
+ @classmethod
356
+ def validate_optional_strings(cls, value: str | None) -> str | None:
357
+ return normalize_optional_string(value)
358
+
359
+ @field_validator("previous_hop_hash", "hop_hash")
360
+ @classmethod
361
+ def validate_hop_hashes(cls, value: str | None) -> str | None:
362
+ if value is None:
363
+ return None
364
+ return validate_sha256_hex(value, field_name="hop hash")
365
+
366
+ @field_validator("hop_signature_algorithm")
367
+ @classmethod
368
+ def validate_hop_signature_algorithm(cls, value: str | None) -> str | None:
369
+ if value is None:
370
+ return None
371
+ normalized = value.strip().lower()
372
+ if normalized not in _ALLOWED_SIGNATURE_ALGORITHMS:
373
+ msg = f"hop_signature_algorithm must be one of {sorted(_ALLOWED_SIGNATURE_ALGORITHMS)}"
374
+ raise ValueError(msg)
375
+ return normalized
376
+
377
+ @model_validator(mode="after")
378
+ def validate_hop(self) -> TransportHop:
379
+ if self.direction == "dispatch" and self.target_node is None:
380
+ raise ValueError("dispatch hops require target_node")
381
+ if self.hop_signature is not None:
382
+ if self.hop_signature_algorithm is None:
383
+ raise ValueError(
384
+ "hop_signature_algorithm is required when hop_signature is present"
385
+ )
386
+ if self.hop_signing_key_id is None:
387
+ raise ValueError("hop_signing_key_id is required when hop_signature is present")
388
+ if self.hop_signature_algorithm is not None and self.hop_signature is None:
389
+ raise ValueError("hop_signature is required when hop_signature_algorithm is present")
390
+ if self.hop_signing_key_id is not None and self.hop_signature is None:
391
+ raise ValueError("hop_signature is required when hop_signing_key_id is present")
392
+ if self.status == "failed" and self.error_code is None and self.error_message is None:
393
+ raise ValueError("failed hops require error_code or error_message")
394
+ return self
395
+
396
+
397
+ class DelegationLink(BaseModel):
398
+ model_config = ConfigDict(frozen=True, extra="forbid")
399
+
400
+ delegator: str
401
+ delegatee: str
402
+ scope: tuple[str, ...]
403
+ granted_at: datetime = Field(default_factory=utc_now)
404
+ expires_at: datetime | None = None
405
+ constraints: dict[str, Any] | None = None
406
+ proof: str | None = None
407
+
408
+ @field_validator("delegator", "delegatee")
409
+ @classmethod
410
+ def validate_nodes(cls, value: str) -> str:
411
+ normalized = value.strip().lower()
412
+ if not normalized:
413
+ raise ValueError("delegation node fields must not be empty")
414
+ return normalized
415
+
416
+ @field_validator("scope", mode="before")
417
+ @classmethod
418
+ def coerce_scope(cls, value: Any) -> Any:
419
+ if isinstance(value, (list, tuple)):
420
+ return tuple(value)
421
+ return value
422
+
423
+ @field_validator("scope")
424
+ @classmethod
425
+ def validate_scope(cls, value: tuple[str, ...]) -> tuple[str, ...]:
426
+ normalized = tuple(item.strip().lower() for item in value if item.strip())
427
+ if not normalized:
428
+ raise ValueError("scope must not be empty")
429
+ if len(set(normalized)) != len(normalized):
430
+ raise ValueError("scope must not contain duplicates")
431
+ return normalized
432
+
433
+ @field_validator("granted_at", "expires_at")
434
+ @classmethod
435
+ def normalize_datetimes(cls, value: datetime | None) -> datetime | None:
436
+ return ensure_utc(value)
437
+
438
+ @field_validator("proof")
439
+ @classmethod
440
+ def validate_proof(cls, value: str | None) -> str | None:
441
+ return normalize_optional_string(value)
442
+
443
+ @model_validator(mode="after")
444
+ def validate_expiry(self) -> DelegationLink:
445
+ if self.expires_at is not None and self.expires_at <= self.granted_at:
446
+ raise ValueError("expires_at must be later than granted_at")
447
+ return self