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.
- pgc_runtime-2.0.0.dist-info/METADATA +181 -0
- pgc_runtime-2.0.0.dist-info/RECORD +28 -0
- pgc_runtime-2.0.0.dist-info/WHEEL +5 -0
- pgc_runtime-2.0.0.dist-info/entry_points.txt +2 -0
- pgc_runtime-2.0.0.dist-info/licenses/LICENSE +67 -0
- pgc_runtime-2.0.0.dist-info/licenses/NOTICE +11 -0
- pgc_runtime-2.0.0.dist-info/top_level.txt +1 -0
- runtime/__init__.py +0 -0
- runtime/api.py +71 -0
- runtime/boot.py +147 -0
- runtime/cli.py +387 -0
- runtime/conformance.py +249 -0
- runtime/ct_errors.py +33 -0
- runtime/ct_execute.py +84 -0
- runtime/ct_executor.py +312 -0
- runtime/dispatcher.py +448 -0
- runtime/evidence.py +245 -0
- runtime/examine/__init__.py +126 -0
- runtime/examine/classifier.py +350 -0
- runtime/examine/cli.py +35 -0
- runtime/examine/hint_engine.py +245 -0
- runtime/examine/locator.py +148 -0
- runtime/examine/parser.py +223 -0
- runtime/examine/reporter.py +110 -0
- runtime/loader.py +360 -0
- runtime/memory.py +125 -0
- runtime/scheduler.py +264 -0
- runtime/trace_viz.py +276 -0
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}"
|