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.
- constellation_node_sdk/__init__.py +123 -0
- constellation_node_sdk/gate/__init__.py +47 -0
- constellation_node_sdk/gate/client.py +108 -0
- constellation_node_sdk/gate/config.py +153 -0
- constellation_node_sdk/gate/errors.py +17 -0
- constellation_node_sdk/gate/policy.py +70 -0
- constellation_node_sdk/gate/registration.py +144 -0
- constellation_node_sdk/orchestrator/__init__.py +3 -0
- constellation_node_sdk/orchestrator/base.py +57 -0
- constellation_node_sdk/orchestrator/merge.py +47 -0
- constellation_node_sdk/orchestrator/packet_builder.py +49 -0
- constellation_node_sdk/orchestrator/retry.py +61 -0
- constellation_node_sdk/orchestrator/state.py +78 -0
- constellation_node_sdk/orchestrator/step_executor.py +87 -0
- constellation_node_sdk/py.typed +0 -0
- constellation_node_sdk/runtime/__init__.py +52 -0
- constellation_node_sdk/runtime/app.py +274 -0
- constellation_node_sdk/runtime/config.py +307 -0
- constellation_node_sdk/runtime/errors.py +112 -0
- constellation_node_sdk/runtime/execution.py +249 -0
- constellation_node_sdk/runtime/handlers.py +45 -0
- constellation_node_sdk/runtime/inbound_policy.py +86 -0
- constellation_node_sdk/runtime/lifecycle.py +29 -0
- constellation_node_sdk/runtime/observability.py +72 -0
- constellation_node_sdk/runtime/preflight.py +122 -0
- constellation_node_sdk/security/__init__.py +36 -0
- constellation_node_sdk/security/delegation.py +57 -0
- constellation_node_sdk/security/errors.py +25 -0
- constellation_node_sdk/security/signing.py +121 -0
- constellation_node_sdk/security/validation.py +230 -0
- constellation_node_sdk/security/verification.py +107 -0
- constellation_node_sdk/transport/__init__.py +88 -0
- constellation_node_sdk/transport/codec.py +21 -0
- constellation_node_sdk/transport/errors.py +41 -0
- constellation_node_sdk/transport/hashing.py +73 -0
- constellation_node_sdk/transport/hop_trace.py +378 -0
- constellation_node_sdk/transport/lineage.py +28 -0
- constellation_node_sdk/transport/models.py +447 -0
- constellation_node_sdk/transport/packet.py +331 -0
- constellation_node_sdk/transport/provenance.py +53 -0
- constellation_node_sdk/transport/tenant.py +76 -0
- constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
- constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
- constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
- 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
|