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.
runtime/dispatcher.py ADDED
@@ -0,0 +1,448 @@
1
+ """
2
+ dispatcher.py — CC-level pipeline executor for the token-native runtime.
3
+
4
+ Executes a single Capability Contract by iterating its compiled pipeline steps.
5
+ Each step is a named-field execution instruction record materialized by the
6
+ compiler — the dispatcher consumes them blindly with no semantic reconstruction.
7
+
8
+ Consumed from RuntimePackage:
9
+ pkg.dispatch.pipeline[cc_addr] — ordered list of step dicts
10
+ pkg.handlers.ct[ct_addr]["ct_ir"] — CT-IR for pure transforms
11
+ pkg.handlers.cs[cs_addr] — handler_ref + cs_metadata for side effects
12
+ pkg.handlers.rb_policy[rb_addr][cs_addr] — per-binding config (path, etc.)
13
+ pkg.vocab.fqdn(addr) — address → FQDN for trace labels
14
+
15
+ Pipeline step format (named-field execution instruction record):
16
+ {
17
+ "addr": int, # CT or CS integer address
18
+ "op": str|None, # None for CT, operation name for CS
19
+ "inputs": dict|None, # resolved input bindings
20
+ "outputs": dict|None, # surface mapping: {cc_field: "$.capability_result.ct_field"}
21
+ "on_result": dict|None, # continuation: {"SUCCESS": "continue", "VIOLATION": "exit"}
22
+ "step_id": str, # symbolic name for cross-step $.results.<step_id>.X refs
23
+ }
24
+
25
+ Input path grammar (step-level, compiler-emitted):
26
+ $.inputs.<field> — CC-level input (from cc_inputs)
27
+ $.results.<step_id>.<field> — previous step output (from step_results)
28
+ <anything else> — literal value (returned as-is)
29
+
30
+ Output path grammar:
31
+ $.capability_result.<field> — extract named field from raw step result
32
+
33
+ on_result actions:
34
+ "continue" — proceed to next step (default if status not listed)
35
+ "exit" — terminate pipeline and return this result_status
36
+
37
+ Result status:
38
+ CT steps: "SUCCESS" on completion, "VIOLATION" on any exception
39
+ CS steps: raw_result["result_status"] (declared by the CS runtime)
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import importlib
45
+ import json
46
+ from typing import Any
47
+
48
+ from runtime.loader import RuntimePackage
49
+ from runtime.evidence import TraceWriter
50
+ from runtime.ct_execute import execute_ct
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Public API
55
+ # ---------------------------------------------------------------------------
56
+
57
+ def execute_cc(
58
+ cc_addr: int,
59
+ rb_addr: int,
60
+ cc_inputs: dict[str, Any],
61
+ pkg: RuntimePackage,
62
+ writer: TraceWriter,
63
+ data_root: str,
64
+ wf_addr: int = -1,
65
+ ) -> tuple[str, dict[str, Any]]:
66
+ """
67
+ Execute a CC pipeline and return (result_status, surface).
68
+
69
+ Args:
70
+ cc_addr: Integer address of the Capability Contract to execute.
71
+ rb_addr: Integer address of the Runtime Binding governing this CC.
72
+ cc_inputs: Resolved inputs for this CC (already bound by the scheduler).
73
+ pkg: Frozen RuntimePackage (loader output).
74
+ writer: TraceWriter for this execution trace.
75
+ data_root: Absolute data directory root (for {{module_data_root}} expansion).
76
+
77
+ Returns:
78
+ (result_status, surface) where:
79
+ result_status — final outcome string (e.g. "SUCCESS", "VIOLATION")
80
+ surface — dict of CC-level named outputs for downstream binding
81
+ """
82
+ cc_fqdn = pkg.vocab.fqdn(cc_addr)
83
+ writer.cc_start(cc_addr, cc_fqdn, cc_inputs)
84
+
85
+ steps = pkg.dispatch.pipeline.get(cc_addr, [])
86
+ step_results: dict[str, dict[str, Any]] = {} # step_id → surface fragment
87
+ surface: dict[str, Any] = {}
88
+ result_status = "SUCCESS"
89
+
90
+ # Build a workflow executor closure for CS types that need nested WF invocation.
91
+ # Injected unconditionally — CSs that don't need it ignore it.
92
+ wf_executor = _make_workflow_executor(pkg, writer, data_root)
93
+
94
+ for step in steps:
95
+ step_addr: int = step["addr"]
96
+ op: str | None = step.get("op")
97
+ inputs_spec: dict = step.get("inputs") or {}
98
+ outputs_spec: dict = step.get("outputs") or {}
99
+ on_result: dict = step.get("on_result") or {}
100
+ # The compiler emits a step's name under `step`; `step_id` was the older spelling and is
101
+ # still honoured. Reading only the latter left `step_results` unkeyed, so every
102
+ # `$.results.<step>.<field>` resolved to None — a binding the grammar accepts, the compiler
103
+ # renders and the runtime silently dropped.
104
+ step_id: str = step.get("step_id") or step.get("step") or ""
105
+
106
+ # Resolve step inputs from CC inputs and accumulated step results
107
+ resolved_inputs = _resolve_step_inputs(inputs_spec, cc_inputs, step_results)
108
+
109
+ # --- Execute step ---
110
+ if op is None:
111
+ # CT step — pure computation, zero side effects
112
+ result_status, raw_result = _execute_ct_step(step_addr, resolved_inputs, pkg)
113
+ else:
114
+ # CS step — controlled side effect via declared handler
115
+ result_status, raw_result = _execute_cs_step(
116
+ step_addr, op, resolved_inputs, rb_addr, pkg, data_root, wf_executor, wf_addr
117
+ )
118
+
119
+ # Apply outputs mapping: {cc_field: "$.capability_result.<ct_field>"} → surface fragment
120
+ surface_fragment = _apply_outputs(outputs_spec, raw_result, step_results)
121
+
122
+ # Store surface fragment + raw capability_result for cross-step references.
123
+ # $.results.<step_id>.<field> — addresses the mapped surface
124
+ # $.results.<step_id>.capability_result.<field> — addresses the raw result
125
+ if step_id:
126
+ step_results[step_id] = {**surface_fragment, "capability_result": raw_result}
127
+
128
+ # Accumulate into CC surface
129
+ surface.update(surface_fragment)
130
+
131
+ # Emit step trace event
132
+ writer.cc_step(
133
+ cc_addr,
134
+ step_addr,
135
+ pkg.vocab.fqdn(step_addr),
136
+ op,
137
+ surface_fragment,
138
+ )
139
+
140
+ # Route: "exit" → break pipeline; "continue" (or unlisted) → proceed
141
+ action = on_result.get(result_status, "continue")
142
+ if action == "exit":
143
+ break
144
+
145
+ # Merge cc_inputs fields not covered by pipeline outputs into the surface.
146
+ # The pipeline outputs_spec is compiler-declared and may not map every input
147
+ # field (e.g. CC_STORE_RESULTS receives sequences/all_terminate/non_terminating
148
+ # as cc_inputs but only declares result_status in its outputs). Merging here
149
+ # restores the expected surface without changing the pipeline contract.
150
+ _INTERNAL_KEYS = {"__store__", "__pgs_store_entity__"}
151
+ for key, value in cc_inputs.items():
152
+ if key not in surface and key not in _INTERNAL_KEYS:
153
+ surface[key] = value
154
+
155
+ writer.cc_complete(cc_addr, cc_fqdn, result_status, surface)
156
+ return result_status, surface
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Step executors
161
+ # ---------------------------------------------------------------------------
162
+
163
+ def _execute_ct_step(
164
+ ct_addr: int,
165
+ resolved_inputs: dict[str, Any],
166
+ pkg: RuntimePackage,
167
+ ) -> tuple[str, dict[str, Any]]:
168
+ """
169
+ Execute a CT (pure transform) step.
170
+
171
+ Returns ("SUCCESS", ct_outputs) on completion.
172
+ Returns ("VIOLATION", {}) on any exception — CT failure is a protocol violation.
173
+ """
174
+ ct_entry = pkg.handlers.ct.get(ct_addr)
175
+ if ct_entry is None:
176
+ raise RuntimeError(
177
+ f"CT addr {ct_addr} not found in handlers — snapshot may be stale"
178
+ )
179
+ ct_ir = ct_entry.get("ct_ir", {})
180
+
181
+ try:
182
+ raw_result = execute_ct(ct_ir, resolved_inputs)
183
+ return "SUCCESS", (raw_result if isinstance(raw_result, dict) else {})
184
+ except Exception:
185
+ # CT exception → protocol VIOLATION; do not propagate
186
+ return "VIOLATION", {}
187
+
188
+
189
+ def _make_workflow_executor(
190
+ pkg: RuntimePackage,
191
+ writer: TraceWriter,
192
+ data_root: str,
193
+ ):
194
+ """
195
+ Build a workflow executor callable for injection into CS config.
196
+
197
+ The returned callable lets CS implementations invoke sub-workflows without
198
+ importing the scheduler directly (avoids circular imports at module load time).
199
+
200
+ Interface: executor(wf_fqdn: str, payload: dict) -> (result_status: str, surface: dict)
201
+ """
202
+ def executor(wf_fqdn_or_addr, payload: dict) -> tuple[str, dict]:
203
+ from runtime.scheduler import run_wf # lazy import — avoids circular dependency
204
+ # Compiler tokenizes nested dict string values to int addresses.
205
+ # CS_WORKFLOW_LOOP_V0 receives int addrs from the compiled mapping; resolve to FQDN here.
206
+ if isinstance(wf_fqdn_or_addr, int):
207
+ wf_fqdn_or_addr = pkg.vocab.fqdn(wf_fqdn_or_addr)
208
+ return run_wf(wf_fqdn_or_addr, payload, pkg, writer, data_root)
209
+ return executor
210
+
211
+
212
+ def _execute_cs_step(
213
+ cs_addr: int,
214
+ op: str,
215
+ resolved_inputs: dict[str, Any],
216
+ rb_addr: int,
217
+ pkg: RuntimePackage,
218
+ data_root: str,
219
+ wf_executor=None,
220
+ wf_addr: int = -1,
221
+ ) -> tuple[str, dict[str, Any]]:
222
+ """
223
+ Execute a CS (side effect) step.
224
+
225
+ Looks up the CS handler and per-binding policy, expands path templates,
226
+ instantiates the CS runtime, and calls execute(op, payload).
227
+
228
+ Returns (result_status, raw_result) where result_status is declared by the CS.
229
+ """
230
+ cs_entry = pkg.handlers.cs.get(cs_addr)
231
+ if cs_entry is None:
232
+ raise RuntimeError(
233
+ f"CS addr {cs_addr} not found in handlers — snapshot may be stale"
234
+ )
235
+
236
+ # Resolve per-RB policy config for this CS
237
+ rb_cs_map = pkg.handlers.rb_policy.get(rb_addr, {})
238
+ policy_entry = rb_cs_map.get(cs_addr, {})
239
+ policy_raw = policy_entry.get("policy") or {}
240
+
241
+ # An act that declared a reach resolves against the composed description the compiler sealed
242
+ # for it — its own entities and the consulted ones, each marked. Handed over, not resolved
243
+ # here: the runtime reads what the composition gives it.
244
+ composed = pkg.handlers.wf_storage.get(wf_addr)
245
+ if composed is not None and policy_raw.get("storage_structure_artifact") is not None:
246
+ policy_raw = {**policy_raw, "storage_structure_artifact": composed}
247
+
248
+ policy = _expand_policy(policy_raw, data_root, pkg.snapshot_root)
249
+
250
+ # A reach reads and never writes. The operation declares whether it writes and the composed
251
+ # description declares whether the entity is consulted, so the refusal rests on two declared
252
+ # facts and infers nothing. Refused before the capability runs, because a write that has
253
+ # happened cannot be unhappened.
254
+ store_entity = resolved_inputs.get("__store__")
255
+ if composed is not None and store_entity:
256
+ entity = (composed.get("frontmatter", {}).get("core", {})
257
+ .get("entity_stores", {}).get(store_entity, {}))
258
+ effect = ((cs_entry.get("cs_metadata", {}).get("operations", {})
259
+ .get("operations", {}).get(op, {})).get("effect"))
260
+ if entity.get("reach") == "consulted" and effect == "write":
261
+ # VIOLATION rather than an exception: a step states its outcome and the act routes on
262
+ # it, which is how every other refusal reaches the workflow. Returned before the
263
+ # capability runs, because a write that has happened cannot be unhappened.
264
+ return "VIOLATION", {
265
+ "result_status": "VIOLATION",
266
+ "refusal": "REACH_IS_READ_ONLY",
267
+ "store": store_entity,
268
+ "described_by": entity.get("described_by"),
269
+ "message": (
270
+ f"act may not write to '{store_entity}': it is described by "
271
+ f"{entity.get('described_by')}, which this act consults and does not own. "
272
+ f"The subdomain that owns a record is the only writer of it"
273
+ ),
274
+ }
275
+
276
+ # Inject workflow executor for CS types that need nested WF invocation.
277
+ # Injected unconditionally — CSs that don't use it ignore the key.
278
+ if wf_executor is not None:
279
+ policy = {**policy, "workflow_executor": wf_executor}
280
+
281
+ # Instantiate CS runtime
282
+ handler_ref = cs_entry["handler_ref"]
283
+ cs_metadata = cs_entry.get("cs_metadata", {})
284
+ cs_fqdn = pkg.vocab.fqdn(cs_addr)
285
+
286
+ mod = importlib.import_module(handler_ref["module"])
287
+ cls = getattr(mod, handler_ref["callable"])
288
+ runtime = cls(config=policy, metadata=cs_metadata, capability_code=cs_fqdn)
289
+
290
+ # Translate __store__ (compiler-emitted entity tag) to __pgs_store_entity__ (CS protocol key)
291
+ store_entity = resolved_inputs.get("__store__")
292
+ cs_inputs = {k: v for k, v in resolved_inputs.items() if k != "__store__"}
293
+ if store_entity:
294
+ cs_inputs["__pgs_store_entity__"] = store_entity
295
+
296
+ raw_result = runtime.execute(op=op, payload=cs_inputs)
297
+ if not isinstance(raw_result, dict):
298
+ raw_result = {}
299
+
300
+ result_status = raw_result.get("result_status", "SUCCESS")
301
+ return result_status, raw_result
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Input resolution
306
+ # ---------------------------------------------------------------------------
307
+
308
+ def _resolve_step_inputs(
309
+ inputs_spec: dict[str, Any],
310
+ cc_inputs: dict[str, Any],
311
+ step_results: dict[str, dict[str, Any]],
312
+ ) -> dict[str, Any]:
313
+ """
314
+ Resolve step input bindings to concrete values.
315
+
316
+ Path grammar:
317
+ $.inputs.<field> → cc_inputs[field]
318
+ $.results.<step_id>.<field> → step_results[step_id][field]
319
+ <other> → literal (returned as-is)
320
+ """
321
+ resolved: dict[str, Any] = {}
322
+ for key, value in inputs_spec.items():
323
+ resolved[key] = _resolve_value(value, cc_inputs, step_results)
324
+ return resolved
325
+
326
+
327
+ def _resolve_value(
328
+ value: Any,
329
+ cc_inputs: dict[str, Any],
330
+ step_results: dict[str, dict[str, Any]],
331
+ ) -> Any:
332
+ """Resolve a single binding value — recursively handles nested dicts/lists."""
333
+ if isinstance(value, str):
334
+ if value.startswith("$.inputs."):
335
+ field = value[len("$.inputs."):]
336
+ return _nested_get(cc_inputs, field)
337
+
338
+ if value.startswith("$.results."):
339
+ # $.results.<step_id>.<field>[.<nested>...]
340
+ after = value[len("$.results."):]
341
+ dot = after.find(".")
342
+ if dot < 0:
343
+ return None # malformed path
344
+ step_id = after[:dot]
345
+ field_path = after[dot + 1:]
346
+ step_surface = step_results.get(step_id, {})
347
+ return _nested_get(step_surface, field_path)
348
+
349
+ # Literal string value
350
+ return value
351
+
352
+ if isinstance(value, dict):
353
+ return {k: _resolve_value(v, cc_inputs, step_results) for k, v in value.items()}
354
+
355
+ if isinstance(value, (list, tuple)):
356
+ return [_resolve_value(v, cc_inputs, step_results) for v in value]
357
+
358
+ return value # int, float, bool, None — returned as-is
359
+
360
+
361
+ def _nested_get(obj: Any, dotted_key: str) -> Any:
362
+ """Traverse a nested dict by a dot-separated key path. Returns None on miss."""
363
+ parts = dotted_key.split(".")
364
+ current = obj
365
+ for part in parts:
366
+ if not isinstance(current, dict):
367
+ return None
368
+ current = current.get(part)
369
+ return current
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Output mapping
374
+ # ---------------------------------------------------------------------------
375
+
376
+ def _apply_outputs(
377
+ outputs_spec: dict[str, str],
378
+ raw_result: dict[str, Any],
379
+ step_results: dict[str, dict[str, Any]],
380
+ ) -> dict[str, Any]:
381
+ """
382
+ Apply the compiler-emitted outputs mapping to the raw step result.
383
+
384
+ Supported path prefixes:
385
+ $.capability_result.<field> — field from this step's raw result
386
+ $.results.<step_id>.<field> — field from a prior step's surface fragment
387
+
388
+ Unmapped fields from raw_result are NOT included — surface is compiler-declared.
389
+ """
390
+ if not outputs_spec:
391
+ return {}
392
+
393
+ fragment: dict[str, Any] = {}
394
+ for surface_field, path in outputs_spec.items():
395
+ if not isinstance(path, str):
396
+ fragment[surface_field] = path
397
+ elif path.startswith("$.capability_result."):
398
+ result_field = path[len("$.capability_result."):]
399
+ fragment[surface_field] = _nested_get(raw_result, result_field)
400
+ elif path.startswith("$.results."):
401
+ after = path[len("$.results."):]
402
+ dot = after.find(".")
403
+ if dot < 0:
404
+ fragment[surface_field] = None
405
+ else:
406
+ step_id = after[:dot]
407
+ field_path = after[dot + 1:]
408
+ step_surface = step_results.get(step_id, {})
409
+ fragment[surface_field] = _nested_get(step_surface, field_path)
410
+ elif path.startswith("$."):
411
+ # Bare $.field path — direct reference into the raw step result
412
+ field_path = path[len("$."):]
413
+ fragment[surface_field] = _nested_get(raw_result, field_path)
414
+ else:
415
+ # Literal value — returned as-is
416
+ fragment[surface_field] = path
417
+
418
+ return fragment
419
+
420
+
421
+ # ---------------------------------------------------------------------------
422
+ # Policy template expansion
423
+ # ---------------------------------------------------------------------------
424
+
425
+ def _expand_policy(
426
+ policy_raw: dict[str, Any], data_root: str, snapshot_root: str = ""
427
+ ) -> dict[str, Any]:
428
+ """
429
+ Expand path templates in the CS policy config.
430
+
431
+ {{module_data_root}} where a capability keeps its state
432
+ {{snapshot_root}} the composition this workflow is executing from
433
+
434
+ The second exists for capabilities that *observe* the composition rather than store state in
435
+ it. Such a capability must be bound to the snapshot it is running inside — if the root came
436
+ from a caller, a workflow could be pointed at a different composition and would report
437
+ confidently about the wrong one.
438
+
439
+ Serializes to JSON, replaces the template strings, deserializes back. Both roots must be
440
+ absolute path strings — never relative.
441
+ """
442
+ if not policy_raw:
443
+ return {}
444
+
445
+ policy_str = json.dumps(policy_raw)
446
+ policy_str = policy_str.replace("{{module_data_root}}", str(data_root).rstrip("/"))
447
+ policy_str = policy_str.replace("{{snapshot_root}}", str(snapshot_root).rstrip("/"))
448
+ return json.loads(policy_str)
runtime/evidence.py ADDED
@@ -0,0 +1,245 @@
1
+ """
2
+ evidence.py — Structured trace event emitter for the token-native runtime.
3
+
4
+ Writes append-only JSONL trace events to the trace output directory.
5
+ Each execution event is a self-contained JSON line with:
6
+ - trace_id — deterministic per-invocation ID
7
+ - event_type — WF_START, CC_START, CC_STEP, CC_COMPLETE, WF_COMPLETE, ERROR
8
+ - domain — domain/structure identifier (e.g. "blockchain")
9
+ - wf_addr — integer WF address
10
+ - cc_addr — integer CC address (None for WF-level events)
11
+ - step_addr — integer step (CT/CS) address (None for CC-level events)
12
+ - step_op — CS operation name or None for CT steps
13
+ - result_status — outcome string (e.g. "SUCCESS") or None
14
+ - detail — arbitrary dict (serializable)
15
+ - ts_ns — monotonic nanosecond timestamp
16
+
17
+ The trace file is written to:
18
+ <traces_root>/<domain>/<wf_code>/<trace_id>/<trace_id>.jsonl
19
+
20
+ subdomain is not known to this module — caller passes the full trace dir path.
21
+
22
+ This module produces the raw JSONL evidence only. Evidence projection
23
+ (execution-path PNG overlay) is handled separately in trace_viz.py.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import time
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+
35
+ CLASSIFICATION_FQDN = "vocabulary::VOCAB_EVIDENCE_CONTENT_CLASSIFICATION_V0"
36
+
37
+
38
+ def _content_classification(snapshot_root: Path) -> dict[str, list[str]]:
39
+ """Which trace content is determinative and which observational, from the sealed composition.
40
+
41
+ `3e` EV-5 requires the distinction be declared rather than inferred, and the declaration is a
42
+ governed artifact rather than a constant here — adding a field is an authoring act, sealed and
43
+ attested. No fallback: evidence written under an unknown classification is evidence a checker
44
+ cannot compare, which is the state EV-5 exists to end.
45
+ """
46
+ for path in (snapshot_root / "canonical").rglob("*.json"):
47
+ if path.name == "metadata.json":
48
+ continue
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ if data.get("fqdn_id") != CLASSIFICATION_FQDN:
51
+ continue
52
+ fm = data.get("frontmatter") or {}
53
+ return {
54
+ "determinative": list(fm["determinative_fields"]["entries"]),
55
+ "observational": list(fm["observational_fields"]["entries"]),
56
+ }
57
+ raise RuntimeError(
58
+ f"{CLASSIFICATION_FQDN} is not in the composition at {snapshot_root} — the "
59
+ f"determinative/observational classification this trace would be written under is not in "
60
+ f"force (3e EV-5)."
61
+ )
62
+
63
+
64
+ class TraceWriter:
65
+ """
66
+ Append-only trace writer for a single workflow execution.
67
+
68
+ Usage:
69
+ writer = TraceWriter(trace_dir, trace_id, domain, wf_addr, wf_fqdn)
70
+ writer.wf_start(payload)
71
+ writer.cc_start(cc_addr, cc_fqdn, cc_inputs)
72
+ writer.cc_step(cc_addr, step_addr, step_fqdn, op, result)
73
+ writer.cc_complete(cc_addr, result_status, outputs)
74
+ writer.wf_complete(result_status)
75
+ writer.close()
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ trace_dir: Path,
81
+ trace_id: str,
82
+ domain: str,
83
+ wf_addr: int,
84
+ wf_fqdn: str,
85
+ snapshot_root: Path,
86
+ snapshot_id: str,
87
+ ) -> None:
88
+ trace_dir.mkdir(parents=True, exist_ok=True)
89
+ self._path = trace_dir / f"{trace_id}.jsonl"
90
+ self._fh = self._path.open("a", encoding="utf-8")
91
+ self._trace_id = trace_id
92
+ self._domain = domain
93
+ self._wf_addr = wf_addr
94
+ self._wf_fqdn = wf_fqdn
95
+
96
+ # The trace states which of its content is determinative, as its first record. Evidence that
97
+ # carried the values and not the classification would be evidence a checker had to guess at,
98
+ # and `3e` §5.2 says a checker that guesses is deciding for itself what governance meant.
99
+ # Carried in the trace rather than looked up later so the record is checkable by a party with
100
+ # no access to the producing system (EV-16, AI-16).
101
+ classification = _content_classification(snapshot_root)
102
+ self._fh.write(json.dumps({
103
+ "trace_schema_version": "v0",
104
+ "event_type": "trace_classification",
105
+ "classified_by": CLASSIFICATION_FQDN,
106
+ # Which closure applied — `3e` §3.1 point 1. At execution the sealed snapshot IS the
107
+ # closure: SN-10 makes it the sole source of governed behaviour, so naming it names
108
+ # every governing element that applied and every rule the closure supplied. A checker
109
+ # holding this id and the snapshot can resolve every address below to what governed it.
110
+ "snapshot_id": snapshot_id,
111
+ **classification,
112
+ }, separators=(",", ":")) + "\n")
113
+ self._fh.flush()
114
+
115
+ # --- Public event methods ---
116
+
117
+ def wf_start(self, payload: dict[str, Any], actor: str | None = None) -> None:
118
+ # Authority: attribute the actor context this WF executes under (declaration/binding only —
119
+ # not an authorization decision). Absent when the WF binds no actor.
120
+ detail = {"wf_fqdn": self._wf_fqdn, "payload_keys": list(payload.keys())}
121
+ if actor:
122
+ detail["actor"] = actor
123
+ self._emit("WF_START", detail=detail)
124
+
125
+ def event(self, ev_fqdn: str, payload: dict[str, Any]) -> None:
126
+ # Observation: a governed domain event (EV_) emitted during execution, recorded in the trace.
127
+ self._emit("EVENT", detail={"ev_fqdn": ev_fqdn, "payload": payload})
128
+
129
+ def cc_start(self, cc_addr: int, cc_fqdn: str, cc_inputs: dict[str, Any]) -> None:
130
+ self._emit("CC_START", cc_addr=cc_addr, detail={"cc_fqdn": cc_fqdn, "inputs": cc_inputs})
131
+
132
+ def cc_step(
133
+ self,
134
+ cc_addr: int,
135
+ step_addr: int,
136
+ step_fqdn: str,
137
+ op: str | None,
138
+ result: dict[str, Any],
139
+ ) -> None:
140
+ self._emit(
141
+ "CC_STEP",
142
+ cc_addr=cc_addr,
143
+ step_addr=step_addr,
144
+ step_op=op,
145
+ detail={"step_fqdn": step_fqdn, "result_keys": list(result.keys())},
146
+ )
147
+
148
+ def cc_complete(
149
+ self,
150
+ cc_addr: int,
151
+ cc_fqdn: str,
152
+ result_status: str,
153
+ outputs: dict[str, Any],
154
+ ) -> None:
155
+ self._emit(
156
+ "CC_COMPLETE",
157
+ cc_addr=cc_addr,
158
+ result_status=result_status,
159
+ detail={"cc_fqdn": cc_fqdn, "output_keys": list(outputs.keys())},
160
+ )
161
+
162
+ def wf_complete(self, result_status: str) -> None:
163
+ self._emit("WF_COMPLETE", result_status=result_status, detail={"wf_fqdn": self._wf_fqdn})
164
+
165
+ def route(self, from_addr: int | None, condition: str, to_addr: int | None) -> None:
166
+ """The routing determination — `3e` §3.1 point 4, the dominant consequence.
167
+
168
+ The trace recorded the sequence of nodes and not the decision that produced it. A reader
169
+ could see that one contract followed another and not that the transition was the one the
170
+ sealed routing table declared for that outcome. Recording the decision makes the path
171
+ checkable against the representation rather than merely consistent with it (EX-15).
172
+
173
+ `to_addr` None is terminal: the outcome routed nowhere, which is the traversal ending.
174
+ """
175
+ self._emit("WF_ROUTE", cc_addr=from_addr, step_addr=to_addr, result_status=condition,
176
+ detail={"terminal": to_addr is None})
177
+
178
+ def error(self, message: str, **extra: Any) -> None:
179
+ self._emit("ERROR", detail={"message": message, **extra})
180
+
181
+ def close(self) -> None:
182
+ if not self._fh.closed:
183
+ self._fh.close()
184
+
185
+ # --- Internal ---
186
+
187
+ def _emit(
188
+ self,
189
+ event_type: str,
190
+ cc_addr: int | None = None,
191
+ step_addr: int | None = None,
192
+ step_op: str | None = None,
193
+ result_status: str | None = None,
194
+ detail: dict[str, Any] | None = None,
195
+ ) -> None:
196
+ event = {
197
+ "trace_schema_version": "v0",
198
+ "trace_id": self._trace_id,
199
+ "event_type": event_type,
200
+ "domain": self._domain,
201
+ "wf_addr": self._wf_addr,
202
+ "cc_addr": cc_addr,
203
+ "step_addr": step_addr,
204
+ "step_op": step_op,
205
+ "result_status": result_status,
206
+ "detail": detail or {},
207
+ "ts_ns": time.monotonic_ns(),
208
+ }
209
+ self._fh.write(json.dumps(event, separators=(",", ":")) + "\n")
210
+ self._fh.flush()
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # Trace ID generation
215
+ # ---------------------------------------------------------------------------
216
+
217
+ def make_trace_id(domain: str, wf_fqdn: str, payload: dict[str, Any]) -> str:
218
+ """
219
+ Generate a human-sortable trace ID from (domain, wf_fqdn, payload).
220
+
221
+ Format: YYYYMMDDTHHMMSSmmmZ__WF_CODE__XXXX
222
+ - Timestamp prefix (UTC, millisecond resolution) — chronological sort
223
+ - WF code extracted from wf_fqdn — self-describing
224
+ - 4-char uppercase hex suffix from sha256(wf_fqdn + payload) — weak idempotency signal
225
+
226
+ Example: 20260524T151422183Z__WF_CREATE_WALLET_V0__A7K2
227
+
228
+ Not purely deterministic (timestamp advances each call). The hash suffix
229
+ signals identical-input executions without enforcing uniqueness.
230
+ """
231
+ from datetime import datetime, timezone
232
+
233
+ now = datetime.now(timezone.utc)
234
+ ts = now.strftime("%Y%m%dT%H%M%S") + f"{now.microsecond // 1000:03d}Z"
235
+ wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_CREATE_WALLET_V0"
236
+
237
+ canonical = json.dumps(
238
+ {"domain": domain, "wf": wf_fqdn, "payload": payload},
239
+ sort_keys=True,
240
+ separators=(",", ":"),
241
+ ensure_ascii=True,
242
+ )
243
+ suffix = hashlib.sha256(canonical.encode()).hexdigest()[:4].upper()
244
+
245
+ return f"{ts}__{wf_code}__{suffix}"