pgc-runtime 2.0.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,110 @@
1
+ """
2
+ reporter.py — Diagnostic report formatting.
3
+
4
+ Governed by: Trace Examiner spec §8
5
+
6
+ Formats DiagnosticReport to terminal output.
7
+ Deterministic, stable format. Pure function.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from runtime.examine.classifier import FailureClass
15
+
16
+
17
+ _SE_SUCCESS_STATUSES = frozenset({"SUCCESS", "ACK", "completed"})
18
+
19
+
20
+ @dataclass
21
+ class SideEffectOutcome:
22
+ """Outcome of a side-effect capability node."""
23
+
24
+ cc_code: str
25
+ result_status: str
26
+
27
+ @property
28
+ def succeeded(self) -> bool:
29
+ return self.result_status in _SE_SUCCESS_STATUSES
30
+
31
+
32
+ @dataclass
33
+ class DiagnosticReport:
34
+ """
35
+ Complete diagnostic report for a trace examination.
36
+
37
+ Per spec §6 data structure.
38
+ """
39
+
40
+ execution_id: str
41
+ workflow_code: str
42
+ has_structural_failure: bool
43
+ failure_class: FailureClass | None
44
+ failing_node: str | None
45
+ reason: str
46
+ artifact_path: str | None
47
+ fix_hint: str
48
+ side_effect_outcomes: list[SideEffectOutcome]
49
+
50
+ def format(self) -> str:
51
+ """Format report for terminal output per spec §8."""
52
+ if not self.has_structural_failure:
53
+ return self._format_success()
54
+ return self._format_failure()
55
+
56
+ def _format_failure(self) -> str:
57
+ sep = "=" * 60
58
+ lines = [
59
+ sep,
60
+ "[trace-examiner] STRUCTURAL FAILURE DETECTED",
61
+ sep,
62
+ f"Trace ID: {self.execution_id}",
63
+ f"Workflow: {self.workflow_code}",
64
+ f"Failing Node: {self.failing_node or '(workflow-level)'}",
65
+ f"Failure Class: {self.failure_class.value if self.failure_class else 'UNKNOWN'}",
66
+ f"Reason: {self.reason}",
67
+ f"Artifact: {self.artifact_path or '(unresolved)'}",
68
+ f"Fix: {self.fix_hint}",
69
+ sep,
70
+ ]
71
+ return "\n".join(lines)
72
+
73
+ def _format_success(self) -> str:
74
+ parts: list[str] = []
75
+
76
+ if self.failure_class == FailureClass.BUSINESS_VIOLATION:
77
+ sep = "-" * 60
78
+ parts.extend([
79
+ sep,
80
+ "[trace-examiner] BUSINESS VIOLATION (not escalated)",
81
+ sep,
82
+ f"Trace ID: {self.execution_id}",
83
+ f"Workflow: {self.workflow_code}",
84
+ f"Node: {self.failing_node or '(unknown)'}",
85
+ f"Reason: {self.reason}",
86
+ sep,
87
+ ])
88
+ else:
89
+ parts.append(
90
+ f"[trace-examiner] {self.workflow_code} — "
91
+ f"{self.execution_id} — no structural failures"
92
+ )
93
+
94
+ if self.side_effect_outcomes:
95
+ parts.append(self._format_side_effect_outcomes())
96
+
97
+ return "\n".join(parts)
98
+
99
+ def _format_side_effect_outcomes(self) -> str:
100
+ """Format side-effect outcomes as a business outcome summary."""
101
+ lines: list[str] = []
102
+ sep = "-" * 60
103
+ lines.append(sep)
104
+ lines.append("[trace-examiner] SIDE-EFFECT OUTCOMES")
105
+ lines.append(sep)
106
+ for outcome in self.side_effect_outcomes:
107
+ indicator = "OK" if outcome.succeeded else "FAILED"
108
+ lines.append(f" [{indicator}] {outcome.cc_code}: {outcome.result_status}")
109
+ lines.append(sep)
110
+ return "\n".join(lines)
runtime/loader.py ADDED
@@ -0,0 +1,360 @@
1
+ """
2
+ loader.py — Token-native snapshot loader for the runtime.
3
+
4
+ Reads the tokenized_snapshot package for a given domain/structure, verifies
5
+ the topology hash against the trust attestation, and returns a frozen
6
+ RuntimePackage dataclass.
7
+
8
+ The loader is the only file that touches the filesystem. Everything above
9
+ it (dispatcher, scheduler, memory, evidence) works only against the frozen
10
+ RuntimePackage.
11
+
12
+ Consumed from the assembled snapshot root (the assembler product; see
13
+ snapshot_assembler/doc/SNAPSHOT_ASSEMBLY_CONTRACT.md):
14
+ tokenized/<domain>/dispatch.json — routing (per-WF), pipeline, entry, bindings
15
+ tokenized/<domain>/handlers.json — ct, cs, rb_policy
16
+ tokenized/<domain>/metadata.json — projection_hash for trust verification
17
+ trust/<domain>/structure_attestation.json — tokenized_projection_hash
18
+ vocabulary/<domain>/forward.json — int_addr_hex → FQDN
19
+ vocabulary/<domain>/reverse.json — FQDN → int_addr_hex
20
+
21
+ Verification contract:
22
+ metadata.json[projection_hash] == structure_attestation.json[tokenized_projection_hash]
23
+ Mismatch → hard failure. No silent skip, no fallback.
24
+
25
+ Address conversion:
26
+ Vocabulary files use hex strings ("0x0035"). The runtime works with ints.
27
+ forward: {int → FQDN} (converted on load)
28
+ reverse: {FQDN → int} (converted on load)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Frozen dataclasses — the RuntimePackage and its sub-tables
41
+ # ---------------------------------------------------------------------------
42
+
43
+ @dataclass(frozen=True)
44
+ class DispatchTable:
45
+ """
46
+ Integer-keyed routing substrate from dispatch.json.
47
+
48
+ routing: {wf_addr: {cc_addr: {condition_addr: {"addr": next_cc_addr, "key": node_key}}}}
49
+ terminal: {wf_addr: {cc_addr: {condition_addr: {"exit": node_key, "type": "EXIT"}}}}
50
+ pipeline: {cc_addr: [step, ...]}
51
+ entry: {wf_addr: {"start": cc_addr, "start_key": node_key, "rb": rb_addr, "in": in_addr}}
52
+ bindings: {wf_addr: {node_key: {input_name: path_or_literal}}}
53
+
54
+ Each pipeline step is a named-field execution instruction record:
55
+ {
56
+ "addr": int, # CT or CS integer address
57
+ "op": str|None, # null for CT, operation name for CS
58
+ "inputs": dict|None, # resolved input bindings ($.inputs.X, $.results.step_id.X, literals)
59
+ "outputs": dict|None, # surface mapping for CT steps ({surface_name: "$.capability_result.field"})
60
+ "on_result": dict|None, # continuation semantics ({"SUCCESS": "continue", "VIOLATION": "exit"})
61
+ "step_id": str, # symbolic step name for $.results.<step_id>.<field> references
62
+ }
63
+
64
+ Routing values carry both the next CC address and the target node_key so that
65
+ the scheduler can disambiguate distinct WF usages of the same shared CC
66
+ (e.g. four denial audit nodes that all bind to CC_RECORD_DENIED_ACTION_V0).
67
+
68
+ Bindings are keyed by node_key (not CC address) for the same reason.
69
+
70
+ All semantics are compiler-materialized. The dispatcher is a blind executor.
71
+ The nested dicts are plain Python dicts (not frozen) — callers must not mutate.
72
+ """
73
+ routing: dict[int, dict[int, dict[int, Any]]]
74
+ # Declared endings. An outcome in neither `routing` nor `terminal` is one the declarations do
75
+ # not answer for, and the traversal refuses rather than ending (`3a` EX-5, `3c` RT-9).
76
+ terminal: dict[int, dict[int, dict[int, Any]]]
77
+ # The input contract each IN gate admits against. An IN that declares none has nothing to
78
+ # determine, and absence is not permission (AI-6).
79
+ admission: dict[int, dict[str, Any]]
80
+ pipeline: dict[int, list[dict]]
81
+ entry: dict[int, dict[str, Any]] # entry may carry "actor" (FQDN) — Authority attribution
82
+ bindings: dict[int, dict[str, dict[str, Any]]]
83
+ emits: dict[int, dict[int, dict[str, str]]] # {wf_addr: {cc_addr: {outcome: EV_FQDN}}} — Observation
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class HandlersTable:
88
+ """
89
+ Implementation dispatch table from handlers.json.
90
+
91
+ ct: {ct_addr: {"ct_ir": {...}}}
92
+ cs: {cs_addr: {"handler_ref": {...}, "cs_metadata": {...}}}
93
+ rb_policy: {rb_addr: {cs_addr: policy_config}}
94
+ wf_storage: {wf_addr: storage_structure_artifact}
95
+
96
+ `wf_storage` is present only for an act that declares a reach: the compiler composed the
97
+ descriptions of the bindings it operates under and marked every entity `owned` or `consulted`.
98
+ An act that consults nothing is absent from it and resolves against its own binding exactly as
99
+ before.
100
+
101
+ All dict keys are ints.
102
+ """
103
+ ct: dict[int, dict[str, Any]]
104
+ cs: dict[int, dict[str, Any]]
105
+ rb_policy: dict[int, dict[int, Any]]
106
+ wf_storage: dict[int, dict[str, Any]]
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class VocabIndex:
111
+ """
112
+ Bidirectional FQDN ↔ integer address index from vocabulary_snapshot.
113
+
114
+ forward: {int_addr → FQDN}
115
+ reverse: {FQDN → int_addr}
116
+ """
117
+ forward: dict[int, str]
118
+ reverse: dict[str, int]
119
+
120
+ def fqdn(self, addr: int) -> str:
121
+ """Resolve integer address to FQDN. Returns hex string if not found."""
122
+ return self.forward.get(addr, f"0x{addr:04X}")
123
+
124
+ def addr(self, fqdn: str) -> int:
125
+ """Resolve FQDN to integer address. Raises KeyError if not found."""
126
+ if fqdn not in self.reverse:
127
+ raise KeyError(f"FQDN not in vocab: {fqdn!r}")
128
+ return self.reverse[fqdn]
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class RuntimePackage:
133
+ """
134
+ Complete frozen runtime substrate for one domain/structure.
135
+
136
+ Produced by load_domain(). All fields are read-only after construction.
137
+ The runtime (scheduler, dispatcher, evidence) operates exclusively against
138
+ this object — no further filesystem access.
139
+ """
140
+ domain: str
141
+ dispatch: DispatchTable
142
+ handlers: HandlersTable
143
+ vocab: VocabIndex
144
+ # The snapshot this package was loaded from. Retained so a capability that observes the
145
+ # composition can be bound to the one it is executing from, rather than being told about a
146
+ # snapshot by its caller — a workflow must not be able to reason about a different composition
147
+ # than the one it is running inside.
148
+ snapshot_root: str = ""
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Public API
153
+ # ---------------------------------------------------------------------------
154
+
155
+ def load_domain(
156
+ snapshot_root: str | Path,
157
+ domain: str,
158
+ *,
159
+ expected_tokenized_hash: str | None = None,
160
+ ) -> RuntimePackage:
161
+ """
162
+ Load and verify one domain's tokenized snapshot from the assembled snapshot root.
163
+
164
+ Args:
165
+ snapshot_root: Absolute path to the assembled snapshot root (contains
166
+ tokenized/ trust/ vocabulary/ per domain). See the assembly contract.
167
+ domain: Domain/structure identifier (e.g. "platform", "blockchain").
168
+ expected_tokenized_hash: If given (the manifest's tokenized projection_hash), the
169
+ on-disk metadata.projection_hash MUST equal it — anchors this domain to
170
+ the manifest root of trust. None skips the manifest anchor (bare load).
171
+
172
+ Returns:
173
+ Frozen RuntimePackage ready for execution.
174
+
175
+ Raises:
176
+ FileNotFoundError: Required snapshot file is missing.
177
+ RuntimeError: Hash verification fails (tampered or stale snapshot).
178
+ ValueError: Malformed snapshot content.
179
+ """
180
+ root = Path(snapshot_root)
181
+
182
+ tok_dir = root / "tokenized" / domain
183
+ trust_dir = root / "trust" / domain
184
+ vocab_dir = root / "vocabulary" / domain
185
+
186
+ # --- Load raw JSON files ---
187
+ dispatch_raw = _load_json(tok_dir / "dispatch.json")
188
+ handlers_raw = _load_json(tok_dir / "handlers.json")
189
+ metadata_raw = _load_json(tok_dir / "metadata.json")
190
+ attestation = _load_json(trust_dir / "structure_attestation.json")
191
+ forward_raw = _load_json(vocab_dir / "forward.json")
192
+ reverse_raw = _load_json(vocab_dir / "reverse.json")
193
+
194
+ # --- Trust verification (compiler self-consistency: tokenized == attestation) ---
195
+ _verify_hash(
196
+ actual = metadata_raw.get("projection_hash", ""),
197
+ expected = attestation.get("tokenized_projection_hash", ""),
198
+ domain = domain,
199
+ )
200
+
201
+ # --- Manifest anchor (root of trust): on-disk hash MUST equal the manifest's claim ---
202
+ if expected_tokenized_hash is not None:
203
+ actual = metadata_raw.get("projection_hash", "")
204
+ if actual != expected_tokenized_hash:
205
+ raise RuntimeError(
206
+ f"[{domain}] Manifest anchor failure: on-disk projection_hash={actual!r} "
207
+ f"!= manifest tokenized projection_hash={expected_tokenized_hash!r}"
208
+ )
209
+
210
+ # --- Build DispatchTable ---
211
+ dispatch = _build_dispatch(dispatch_raw)
212
+
213
+ # --- Build HandlersTable ---
214
+ handlers = _build_handlers(handlers_raw)
215
+
216
+ # --- Build VocabIndex ---
217
+ vocab = _build_vocab(forward_raw, reverse_raw)
218
+
219
+ return RuntimePackage(
220
+ domain = domain,
221
+ dispatch = dispatch,
222
+ handlers = handlers,
223
+ vocab = vocab,
224
+ snapshot_root = str(root),
225
+ )
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Internal helpers
230
+ # ---------------------------------------------------------------------------
231
+
232
+ def _load_json(path: Path) -> dict:
233
+ if not path.exists():
234
+ raise FileNotFoundError(f"Required snapshot file missing: {path}")
235
+ with path.open(encoding="utf-8") as f:
236
+ return json.load(f)
237
+
238
+
239
+ def _verify_hash(actual: str, expected: str, domain: str) -> None:
240
+ if not actual or not expected:
241
+ raise RuntimeError(
242
+ f"[{domain}] Trust verification failed: "
243
+ f"projection_hash or tokenized_projection_hash is empty."
244
+ )
245
+ if actual != expected:
246
+ raise RuntimeError(
247
+ f"[{domain}] Snapshot integrity failure: "
248
+ f"metadata.projection_hash={actual!r} "
249
+ f"!= attestation.tokenized_projection_hash={expected!r}"
250
+ )
251
+
252
+
253
+ def _build_dispatch(raw: dict) -> DispatchTable:
254
+ """
255
+ Parse dispatch.json into integer-keyed DispatchTable.
256
+
257
+ JSON keys are strings (JSON spec). Addresses are int values.
258
+ Outer WF and CC keys are converted to int. Routing values are
259
+ {"addr": int, "key": str} dicts — preserved as-is. Bindings keys
260
+ are node_key strings — preserved as-is (not int-converted).
261
+ """
262
+ routing: dict[int, dict[int, dict[int, Any]]] = {}
263
+ for wf_key, cc_map in raw.get("routing", {}).items():
264
+ wf_addr = int(wf_key)
265
+ routing[wf_addr] = {
266
+ int(cc_key): {int(cond): tgt for cond, tgt in cond_map.items()}
267
+ for cc_key, cond_map in cc_map.items()
268
+ }
269
+
270
+ terminal: dict[int, dict[int, dict[int, Any]]] = {}
271
+ for wf_key, cc_map in raw.get("terminal", {}).items():
272
+ wf_addr = int(wf_key)
273
+ terminal[wf_addr] = {
274
+ int(cc_key): {int(cond): tgt for cond, tgt in cond_map.items()}
275
+ for cc_key, cond_map in cc_map.items()
276
+ }
277
+
278
+ admission: dict[int, dict[str, Any]] = {
279
+ int(k): v for k, v in raw.get("admission", {}).items()
280
+ }
281
+
282
+ pipeline: dict[int, list[dict]] = {}
283
+ for cc_key, steps in raw.get("pipeline", {}).items():
284
+ cc_addr = int(cc_key)
285
+ pipeline[cc_addr] = steps # list of named-field step dicts
286
+
287
+ entry: dict[int, dict[str, Any]] = {}
288
+ for wf_key, e in raw.get("entry", {}).items():
289
+ wf_addr = int(wf_key)
290
+ # int-convert only numeric values (start, rb, in); leave start_key as str
291
+ entry[wf_addr] = {
292
+ k: (int(v) if isinstance(v, (int, float)) else v)
293
+ for k, v in e.items()
294
+ }
295
+
296
+ bindings: dict[int, dict[str, dict[str, Any]]] = {}
297
+ for wf_key, node_map in raw.get("bindings", {}).items():
298
+ wf_addr = int(wf_key)
299
+ # node_map keys are node_key strings (e.g. "CC_NORMALIZE_AGENT_REQUEST_V0")
300
+ bindings[wf_addr] = {node_key: inp for node_key, inp in node_map.items()}
301
+
302
+ # emits: {wf_addr: {cc_addr: {outcome_str: EV_FQDN}}} — domain events to emit on a CC outcome
303
+ emits: dict[int, dict[int, dict[str, str]]] = {}
304
+ for wf_key, cc_map in raw.get("emits", {}).items():
305
+ emits[int(wf_key)] = {
306
+ int(cc_key): {outcome: ev for outcome, ev in outcome_map.items()}
307
+ for cc_key, outcome_map in cc_map.items()
308
+ }
309
+
310
+ return DispatchTable(
311
+ routing = routing,
312
+ terminal = terminal,
313
+ admission = admission,
314
+ pipeline = pipeline,
315
+ entry = entry,
316
+ bindings = bindings,
317
+ emits = emits,
318
+ )
319
+
320
+
321
+ def _build_handlers(raw: dict) -> HandlersTable:
322
+ """
323
+ Parse handlers.json into integer-keyed HandlersTable.
324
+ """
325
+ ct: dict[int, dict[str, Any]] = {
326
+ int(k): v for k, v in raw.get("ct", {}).items()
327
+ }
328
+ cs: dict[int, dict[str, Any]] = {
329
+ int(k): v for k, v in raw.get("cs", {}).items()
330
+ }
331
+ rb_policy: dict[int, dict[int, Any]] = {}
332
+ for rb_key, cs_map in raw.get("rb_policy", {}).items():
333
+ rb_addr = int(rb_key)
334
+ rb_policy[rb_addr] = {int(cs_key): policy for cs_key, policy in cs_map.items()}
335
+
336
+ wf_storage: dict[int, dict[str, Any]] = {
337
+ int(k): v for k, v in raw.get("wf_storage", {}).items()
338
+ }
339
+
340
+ return HandlersTable(ct=ct, cs=cs, rb_policy=rb_policy, wf_storage=wf_storage)
341
+
342
+
343
+ def _build_vocab(forward_raw: dict, reverse_raw: dict) -> VocabIndex:
344
+ """
345
+ Build bidirectional vocab index.
346
+
347
+ forward.json keys are hex strings like "0x0035" → FQDN string.
348
+ reverse.json keys are FQDN strings → hex string like "0x0035".
349
+
350
+ Both are converted to int ↔ FQDN mappings.
351
+ """
352
+ forward: dict[int, str] = {}
353
+ for hex_key, fqdn in forward_raw.items():
354
+ forward[int(hex_key, 16)] = fqdn
355
+
356
+ reverse: dict[str, int] = {}
357
+ for fqdn, hex_val in reverse_raw.items():
358
+ reverse[fqdn] = int(hex_val, 16)
359
+
360
+ return VocabIndex(forward=forward, reverse=reverse)
runtime/memory.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ memory.py — Execution context for a single workflow run.
3
+
4
+ Holds the initial payload and accumulates CC result surfaces as the
5
+ workflow progresses. Provides JSONPath resolution for CC input bindings.
6
+
7
+ Path grammar (compile-time allocated, runtime resolved):
8
+ $.payload.<field> — field from the workflow payload
9
+ $.inputs.<field> — alias for $.payload.<field> (CC-step scope)
10
+ $.results.<cc_addr>.<field> — field from a previous CC's result surface
11
+ <anything else> — literal value (returned as-is)
12
+
13
+ No dynamic path construction. All paths are emitted by the compiler.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+
21
+ class ExecutionContext:
22
+ """
23
+ Mutable execution context for one workflow invocation.
24
+
25
+ - payload: initial input dict (never mutated after construction)
26
+ - results: accumulated CC result surfaces keyed by integer CC address
27
+ - actor: the Authority actor context this workflow executes under (FQDN), bound at WF entry
28
+ and carried through the run. Present for attribution/propagation only — no
29
+ authorization is enforced here (that belongs to the authority model).
30
+ """
31
+
32
+ __slots__ = ("_payload", "_results", "_actor")
33
+
34
+ def __init__(self, payload: dict[str, Any], actor: str | None = None) -> None:
35
+ self._payload: dict[str, Any] = dict(payload)
36
+ self._results: dict[int, dict[str, Any]] = {}
37
+ self._actor: str | None = actor
38
+
39
+ def record_result(self, cc_addr: int, surface: dict[str, Any]) -> None:
40
+ """Store a CC's output surface for downstream bindings."""
41
+ self._results[cc_addr] = dict(surface)
42
+
43
+ def resolve(self, path: str) -> Any:
44
+ """
45
+ Resolve a binding path to its value.
46
+
47
+ Supports:
48
+ $.payload.<field> — payload lookup (nested via dots)
49
+ $.inputs.<field> — same as $.payload.<field>
50
+ $.results.<cc_addr>.<field> — previous CC result lookup
51
+ <literal> — returned as-is
52
+ """
53
+ if not isinstance(path, str):
54
+ if isinstance(path, dict):
55
+ return {k: self.resolve(v) for k, v in path.items()}
56
+ if isinstance(path, (list, tuple)):
57
+ return [self.resolve(v) for v in path]
58
+ return path # int, float, bool, None — returned as-is
59
+
60
+ if path.startswith("$.payload."):
61
+ return _nested_get(self._payload, path[len("$.payload."):])
62
+
63
+ if path.startswith("$.inputs."):
64
+ return _nested_get(self._payload, path[len("$.inputs."):])
65
+
66
+ if path.startswith("$.results."):
67
+ # Format: $.results.<cc_addr>.<field>[.<nested>...]
68
+ after = path[len("$.results."):]
69
+ dot = after.find(".")
70
+ if dot < 0:
71
+ return None # malformed path
72
+ try:
73
+ cc_addr = int(after[:dot])
74
+ except ValueError:
75
+ return None # non-integer CC addr in path
76
+ field_path = after[dot + 1:]
77
+ surface = self._results.get(cc_addr)
78
+ if surface is None:
79
+ return None
80
+ return _nested_get(surface, field_path)
81
+
82
+ # Literal value
83
+ return path
84
+
85
+ def resolve_inputs(self, bindings: dict[str, Any]) -> dict[str, Any]:
86
+ """
87
+ Resolve a full bindings dict → concrete input values.
88
+
89
+ Each value is a path string or a literal. Returns a plain dict
90
+ with the same keys and resolved values.
91
+ """
92
+ return {k: self.resolve(v) for k, v in bindings.items()}
93
+
94
+ @property
95
+ def payload(self) -> dict[str, Any]:
96
+ return self._payload
97
+
98
+ @property
99
+ def actor(self) -> str | None:
100
+ """Actor context (FQDN) this workflow executes under. None if no actor is bound."""
101
+ return self._actor
102
+
103
+ @property
104
+ def results(self) -> dict[int, dict[str, Any]]:
105
+ return dict(self._results)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Internal helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def _nested_get(obj: Any, dotted_key: str) -> Any:
113
+ """
114
+ Traverse a nested dict by a dot-separated key path.
115
+
116
+ Example: _nested_get({"a": {"b": 3}}, "a.b") → 3
117
+ Returns None for any missing key.
118
+ """
119
+ parts = dotted_key.split(".")
120
+ current = obj
121
+ for part in parts:
122
+ if not isinstance(current, dict):
123
+ return None
124
+ current = current.get(part)
125
+ return current