pgs-runtime 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {pgs_runtime-0.4.0/pgs_runtime.egg-info → pgs_runtime-0.6.0}/PKG-INFO +3 -2
  2. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/README.md +2 -1
  3. pgs_runtime-0.6.0/pgs_runtime/api.py +61 -0
  4. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/cli.py +37 -66
  5. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/conformance.py +19 -2
  6. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/dispatcher.py +34 -1
  7. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/loader.py +28 -16
  8. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/scheduler.py +16 -4
  9. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/trace_viz.py +21 -9
  10. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0/pgs_runtime.egg-info}/PKG-INFO +3 -2
  11. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime.egg-info/SOURCES.txt +1 -0
  12. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pyproject.toml +1 -1
  13. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/LICENSE +0 -0
  14. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/MANIFEST.in +0 -0
  15. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/NOTICE +0 -0
  16. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/__init__.py +0 -0
  17. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/ct_errors.py +0 -0
  18. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/ct_execute.py +0 -0
  19. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/ct_executor.py +0 -0
  20. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/evidence.py +0 -0
  21. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/__init__.py +0 -0
  22. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/classifier.py +0 -0
  23. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/cli.py +0 -0
  24. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/conformance_test.py +0 -0
  25. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/hint_engine.py +0 -0
  26. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/locator.py +0 -0
  27. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/parser.py +0 -0
  28. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/examine/reporter.py +0 -0
  29. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/memory.py +0 -0
  30. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime/server.py +0 -0
  31. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime.egg-info/dependency_links.txt +0 -0
  32. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime.egg-info/entry_points.txt +0 -0
  33. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/pgs_runtime.egg-info/top_level.txt +0 -0
  34. {pgs_runtime-0.4.0 → pgs_runtime-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgs_runtime
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: PGS runtime for Protocol-Governed Systems
5
5
  Author: Bachi
6
6
  License-Expression: Apache-2.0
@@ -29,7 +29,7 @@ It does not discover behavior. It does not interpret intent. It does not contain
29
29
 
30
30
  Behavior is declared in protocol, executed by runtime, implemented in capabilities, and observed via traces and state.
31
31
 
32
- > **New to PGS?** This is one of eight repositories in the Protocol-Governed Systems ecosystem.
32
+ > **New to PGS?** This is one of the repositories in the Protocol-Governed Systems ecosystem.
33
33
  > For orientation, architecture overview, and end-to-end execution, start at [pgs_workspace](https://github.com/bachipeachy/pgs_workspace).
34
34
 
35
35
  ---
@@ -172,6 +172,7 @@ PGS is intentionally split into layers:
172
172
  | **Execution** | **`pgs_runtime` ← here** | **Traverse compiled graph deterministically** |
173
173
  | Capabilities | `pgs_capabilities` | Provide CT/CS implementations |
174
174
  | Domains | `pgs_blockchain`, `pgs_ai_governance` | Real-world workflows |
175
+ | Change Mgmt | `pgs_change_mgmt` | Governed SDLC — Change Request to Authoring Mandate (new in v0.5.0) |
175
176
  | Entry point | `pgs_workspace` | Run and observe |
176
177
 
177
178
  ---
@@ -7,7 +7,7 @@ It does not discover behavior. It does not interpret intent. It does not contain
7
7
 
8
8
  Behavior is declared in protocol, executed by runtime, implemented in capabilities, and observed via traces and state.
9
9
 
10
- > **New to PGS?** This is one of eight repositories in the Protocol-Governed Systems ecosystem.
10
+ > **New to PGS?** This is one of the repositories in the Protocol-Governed Systems ecosystem.
11
11
  > For orientation, architecture overview, and end-to-end execution, start at [pgs_workspace](https://github.com/bachipeachy/pgs_workspace).
12
12
 
13
13
  ---
@@ -150,6 +150,7 @@ PGS is intentionally split into layers:
150
150
  | **Execution** | **`pgs_runtime` ← here** | **Traverse compiled graph deterministically** |
151
151
  | Capabilities | `pgs_capabilities` | Provide CT/CS implementations |
152
152
  | Domains | `pgs_blockchain`, `pgs_ai_governance` | Real-world workflows |
153
+ | Change Mgmt | `pgs_change_mgmt` | Governed SDLC — Change Request to Authoring Mandate (new in v0.5.0) |
153
154
  | Entry point | `pgs_workspace` | Run and observe |
154
155
 
155
156
  ---
@@ -0,0 +1,61 @@
1
+ """Programmatic runtime entry — run a workflow against a snapshot and return its result surface.
2
+
3
+ This is the in-process core behind `pgs_runtime run`: it loads the domain snapshot, opens a trace, drives
4
+ the workflow topology via `scheduler.run_wf`, and returns the terminal status **and result surface**. The
5
+ CLI is a thin wrapper over it, and out-of-process consumers that need the workflow surface programmatically
6
+ (e.g. the change-management validation pipeline) call this directly instead of shelling out and parsing
7
+ stdout or data files.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pgs_runtime.evidence import TraceWriter, make_trace_id
16
+ from pgs_runtime.loader import load_domain
17
+ from pgs_runtime.scheduler import run_wf
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RunResult:
22
+ status: str # terminal workflow outcome (e.g. "SUCCESS", "ACK", "VIOLATION")
23
+ surface: dict[str, Any] # workflow result surface (the observable outputs)
24
+ trace_id: str
25
+ trace_dir: Path
26
+
27
+
28
+ def run_workflow(
29
+ *,
30
+ wf_fqdn: str,
31
+ payload: dict[str, Any],
32
+ data_root: str | Path,
33
+ workspace: str | Path,
34
+ ) -> RunResult:
35
+ """Execute a workflow against the snapshot in `workspace` and return its `(status, surface, trace)`.
36
+
37
+ Deterministic and side-effect-scoped: writes only to `data_root` (CS state) and `workspace/traces/`.
38
+ Raises on load/vocab errors and propagates runtime exceptions (after recording them to the trace).
39
+ """
40
+ workspace = Path(workspace)
41
+ domain = wf_fqdn.split("::")[0]
42
+ pkg = load_domain(workspace, domain)
43
+
44
+ trace_id = make_trace_id(domain, wf_fqdn, payload)
45
+ wf_code = wf_fqdn.split("::")[-1]
46
+ trace_dir = workspace / "traces" / domain / wf_code / trace_id
47
+ trace_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ wf_addr = pkg.vocab.addr(wf_fqdn) # KeyError if the WF is not in the snapshot vocab
50
+ writer = TraceWriter(trace_dir=trace_dir, trace_id=trace_id, domain=domain,
51
+ wf_addr=wf_addr, wf_fqdn=wf_fqdn)
52
+ try:
53
+ status, surface = run_wf(wf_fqdn=wf_fqdn, payload=payload, pkg=pkg,
54
+ writer=writer, data_root=str(data_root))
55
+ except Exception as exc:
56
+ writer.error(str(exc))
57
+ raise
58
+ finally:
59
+ writer.close()
60
+
61
+ return RunResult(status=status, surface=surface or {}, trace_id=trace_id, trace_dir=trace_dir)
@@ -2,9 +2,9 @@
2
2
  cli.py — Token-native CLI entry point for the pgs_runtime.
3
3
 
4
4
  Commands:
5
- run — Execute a workflow against the tokenized snapshot.
6
- examine — Analyze a completed trace file and print a diagnostic report.
7
- visualize — Render execution-path PNG from a completed trace file.
5
+ run — Execute a workflow against the tokenized snapshot.
6
+ examine — Analyze a completed trace file and print a diagnostic report.
7
+ behavior-logic — Render execution-path PNG from a completed trace file.
8
8
 
9
9
  Execution path (run):
10
10
  1. Load tokenized snapshot for the domain via loader.load_domain()
@@ -13,7 +13,7 @@ Execution path (run):
13
13
  3. Open TraceWriter at traces/<domain>/<wf_code>/<trace_id>/
14
14
  4. Drive workflow topology via scheduler.run_wf()
15
15
  5. Print result summary; exit 1 on non-SUCCESS
16
- 6. If --visualize: invoke evidence projection (trace_viz) to render PNG
16
+ 6. If --behavior-logic: invoke evidence projection (trace_viz) to render PNG
17
17
 
18
18
  All runtime behavior comes from the compiled tokenized_snapshot.
19
19
  The CLI does not implement any domain logic.
@@ -28,9 +28,7 @@ import sys
28
28
  import time
29
29
  from pathlib import Path
30
30
 
31
- from pgs_runtime.evidence import TraceWriter, make_trace_id
32
- from pgs_runtime.loader import load_domain
33
- from pgs_runtime.scheduler import run_wf
31
+ from pgs_runtime.api import run_workflow
34
32
 
35
33
 
36
34
  # ---------------------------------------------------------------------------
@@ -71,10 +69,11 @@ def _build_parser() -> argparse.ArgumentParser:
71
69
  "tokenized_snapshot/ and traces/ live here",
72
70
  )
73
71
 
74
- # ── run: optional visualization flag ─────────────────────────
72
+ # ── run: optional behavior-logic flag ────────────────────────
75
73
  run_p.add_argument(
76
- "--visualize",
74
+ "--behavior-logic",
77
75
  action="store_true",
76
+ dest="behavior_logic",
78
77
  help="Render execution-path PNG after run (requires graphviz)",
79
78
  )
80
79
 
@@ -86,17 +85,17 @@ def _build_parser() -> argparse.ArgumentParser:
86
85
  help="Path to a completed .jsonl trace file",
87
86
  )
88
87
 
89
- # ── visualize ─────────────────────────────────────────────────
90
- viz_p = subs.add_parser(
91
- "visualize",
88
+ # ── behavior-logic ────────────────────────────────────────────
89
+ bl_p = subs.add_parser(
90
+ "behavior-logic",
92
91
  help="Render execution-path PNG from a completed trace file",
93
92
  )
94
- viz_p.add_argument(
93
+ bl_p.add_argument(
95
94
  "trace_file",
96
95
  metavar="FILE",
97
96
  help="Path to a completed .jsonl trace file",
98
97
  )
99
- viz_p.add_argument(
98
+ bl_p.add_argument(
100
99
  "--workspace",
101
100
  metavar="PATH",
102
101
  help="Absolute path to pgs_workspace root (or set PGS_WORKSPACE)",
@@ -135,62 +134,34 @@ def _handle_run(args: argparse.Namespace) -> None:
135
134
  # Load payload
136
135
  payload = _load_payload(args.payload)
137
136
 
138
- # Load tokenized snapshot (verifies hash; raises on mismatch or missing files)
137
+ # Drive the workflow via the programmatic API (single implementation; also used by the CM validation
138
+ # pipeline). The API loads the snapshot, opens the trace, and returns status + surface.
139
139
  print(f"[pgs_runtime] Loading {domain} snapshot...")
140
+ t0 = time.monotonic()
140
141
  try:
141
- pkg = load_domain(workspace, domain)
142
+ run = run_workflow(wf_fqdn=wf_fqdn, payload=payload,
143
+ data_root=str(data_root), workspace=workspace)
144
+ except KeyError as exc:
145
+ _fatal(f"WF FQDN not in vocab: {exc}")
142
146
  except (FileNotFoundError, RuntimeError, ValueError) as exc:
143
147
  _fatal(str(exc))
148
+ except Exception as exc:
149
+ _fatal(f"Runtime error: {exc}")
144
150
 
145
- # Generate trace ID and resolve trace directory
146
- # Structure: traces/<domain>/<WF_CODE>/<trace_id>/
147
- trace_id = make_trace_id(domain, wf_fqdn, payload)
148
- wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_CREATE_WALLET_V0"
149
- trace_dir = workspace / "traces" / domain / wf_code / trace_id
150
- trace_dir.mkdir(parents=True, exist_ok=True)
151
+ result_status, surface = run.status, run.surface
152
+ trace_id, trace_dir = run.trace_id, run.trace_dir
153
+ duration_ms = int((time.monotonic() - t0) * 1000)
151
154
 
152
155
  print(f"[pgs_runtime] Workflow: {wf_fqdn}")
153
156
  print(f"[pgs_runtime] Trace ID: {trace_id}")
154
157
  print(f"[pgs_runtime] Trace dir: {trace_dir}")
155
158
  print()
156
159
 
157
- # Resolve WF address for TraceWriter
158
- try:
159
- wf_addr = pkg.vocab.addr(wf_fqdn)
160
- except KeyError:
161
- _fatal(f"WF FQDN not in vocab: {wf_fqdn!r}")
162
-
163
- writer = TraceWriter(
164
- trace_dir = trace_dir,
165
- trace_id = trace_id,
166
- domain = domain,
167
- wf_addr = wf_addr,
168
- wf_fqdn = wf_fqdn,
169
- )
170
-
171
- t0 = time.monotonic()
172
- try:
173
- result_status, surface = run_wf(
174
- wf_fqdn = wf_fqdn,
175
- payload = payload,
176
- pkg = pkg,
177
- writer = writer,
178
- data_root = str(data_root),
179
- )
180
- except Exception as exc:
181
- writer.error(str(exc))
182
- writer.close()
183
- _fatal(f"Runtime error: {exc}")
184
- finally:
185
- writer.close()
186
-
187
- duration_ms = int((time.monotonic() - t0) * 1000)
188
-
189
160
  # Evidence projection: render execution-path PNG if requested
190
161
  trace_path = trace_dir / f"{trace_id}.jsonl"
191
162
  png_path = None
192
- if args.visualize:
193
- png_path = _render_visualization(workspace, trace_path)
163
+ if args.behavior_logic:
164
+ png_path = _render_behavior_logic(workspace, trace_path)
194
165
 
195
166
  print("=" * 60)
196
167
  print("[pgs_runtime] Workflow Complete")
@@ -202,16 +173,16 @@ def _handle_run(args: argparse.Namespace) -> None:
202
173
  if surface:
203
174
  print(f"Output: {json.dumps(surface, separators=(',', ':'))}")
204
175
  if png_path:
205
- print(f"Visual: {png_path}")
206
- elif args.visualize:
207
- print("Visual: (graphviz not available — PNG skipped)")
176
+ print(f"Graph: {png_path}")
177
+ elif args.behavior_logic:
178
+ print("Graph: (graphviz not available — PNG skipped)")
208
179
  print("=" * 60)
209
180
 
210
181
  if result_status not in ("SUCCESS", "ALREADY_EXISTS"):
211
182
  sys.exit(1)
212
183
 
213
184
 
214
- def _handle_visualize(args: argparse.Namespace) -> None:
185
+ def _handle_behavior_logic(args: argparse.Namespace) -> None:
215
186
  trace_path = Path(args.trace_file)
216
187
  if not trace_path.exists():
217
188
  _fatal(f"Trace file not found: {args.trace_file}")
@@ -223,12 +194,12 @@ def _handle_visualize(args: argparse.Namespace) -> None:
223
194
  if not workspace.is_absolute():
224
195
  _fatal(f"--workspace must be an absolute path, got: {workspace_str}")
225
196
 
226
- png_path = _render_visualization(workspace, trace_path)
197
+ png_path = _render_behavior_logic(workspace, trace_path)
227
198
  if png_path:
228
199
  print(f"[pgs_runtime] Execution path PNG: {png_path}")
229
200
  else:
230
201
  print(
231
- "[pgs_runtime] Visualization skipped — graphviz (dot) not available.",
202
+ "[pgs_runtime] Behavior logic render skipped — graphviz (dot) not available.",
232
203
  file=sys.stderr,
233
204
  )
234
205
  sys.exit(1)
@@ -263,13 +234,13 @@ def _handle_examine(args: argparse.Namespace) -> None:
263
234
  # Utilities
264
235
  # ---------------------------------------------------------------------------
265
236
 
266
- def _render_visualization(workspace: Path, trace_path: Path) -> "Path | None":
237
+ def _render_behavior_logic(workspace: Path, trace_path: Path) -> "Path | None":
267
238
  """Invoke evidence projection to render execution-path PNG. Best-effort."""
268
239
  from pgs_runtime.trace_viz import render_trace_png
269
240
  try:
270
241
  return render_trace_png(workspace, trace_path)
271
242
  except (FileNotFoundError, ValueError) as exc:
272
- print(f"[pgs_runtime] Visualization error: {exc}", file=sys.stderr)
243
+ print(f"[pgs_runtime] Behavior logic render error: {exc}", file=sys.stderr)
273
244
  return None
274
245
 
275
246
 
@@ -302,8 +273,8 @@ def main() -> None:
302
273
  _handle_run(args)
303
274
  elif args.command == "examine":
304
275
  _handle_examine(args)
305
- elif args.command == "visualize":
306
- _handle_visualize(args)
276
+ elif args.command == "behavior-logic":
277
+ _handle_behavior_logic(args)
307
278
 
308
279
 
309
280
  if __name__ == "__main__":
@@ -198,10 +198,22 @@ def run(snapshot_root: Path) -> ConformanceResult:
198
198
  expected = artifact.get("expected", {})
199
199
  assertions = artifact.get("assertions", {})
200
200
 
201
+ expected_outcome = artifact.get("expected_outcome", "SUCCESS")
202
+
201
203
  try:
202
204
  vars_result = executor.execute(ct_ir=ct_ir, inputs=inputs)
203
205
  actual = _resolve_outputs(ct_ir, vars_result)
204
206
 
207
+ if expected_outcome == "VIOLATION":
208
+ # CT completed without error but a VIOLATION was expected.
209
+ result.failed += 1
210
+ result.cases.append(CaseResult(
211
+ fqdn=fqdn,
212
+ passed=False,
213
+ error="expected CTExecutionError (VIOLATION) but CT completed without raising",
214
+ ))
215
+ continue
216
+
205
217
  # Structural assertions validate non-deterministic fields by shape/type/size.
206
218
  # Fields covered by assertions are excluded from exact-match comparison.
207
219
  assertion_error = _assert_structural(actual, assertions) if assertions else None
@@ -226,7 +238,12 @@ def run(snapshot_root: Path) -> ConformanceResult:
226
238
  result.cases.append(CaseResult(fqdn=fqdn, passed=True))
227
239
 
228
240
  except CTExecutionError as e:
229
- result.failed += 1
230
- result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=str(e)))
241
+ if expected_outcome == "VIOLATION":
242
+ # CT raised as expected — PASS.
243
+ result.passed += 1
244
+ result.cases.append(CaseResult(fqdn=fqdn, passed=True))
245
+ else:
246
+ result.failed += 1
247
+ result.cases.append(CaseResult(fqdn=fqdn, passed=False, error=str(e)))
231
248
 
232
249
  return result
@@ -86,6 +86,10 @@ def execute_cc(
86
86
  surface: dict[str, Any] = {}
87
87
  result_status = "SUCCESS"
88
88
 
89
+ # Build a workflow executor closure for CS types that need nested WF invocation.
90
+ # Injected unconditionally — CSs that don't need it ignore it.
91
+ wf_executor = _make_workflow_executor(pkg, writer, data_root)
92
+
89
93
  for step in steps:
90
94
  step_addr: int = step["addr"]
91
95
  op: str | None = step.get("op")
@@ -104,7 +108,7 @@ def execute_cc(
104
108
  else:
105
109
  # CS step — controlled side effect via declared handler
106
110
  result_status, raw_result = _execute_cs_step(
107
- step_addr, op, resolved_inputs, rb_addr, pkg, data_root
111
+ step_addr, op, resolved_inputs, rb_addr, pkg, data_root, wf_executor
108
112
  )
109
113
 
110
114
  # Apply outputs mapping: {cc_field: "$.capability_result.<ct_field>"} → surface fragment
@@ -177,6 +181,29 @@ def _execute_ct_step(
177
181
  return "VIOLATION", {}
178
182
 
179
183
 
184
+ def _make_workflow_executor(
185
+ pkg: RuntimePackage,
186
+ writer: TraceWriter,
187
+ data_root: str,
188
+ ):
189
+ """
190
+ Build a workflow executor callable for injection into CS config.
191
+
192
+ The returned callable lets CS implementations invoke sub-workflows without
193
+ importing the scheduler directly (avoids circular imports at module load time).
194
+
195
+ Interface: executor(wf_fqdn: str, payload: dict) -> (result_status: str, surface: dict)
196
+ """
197
+ def executor(wf_fqdn_or_addr, payload: dict) -> tuple[str, dict]:
198
+ from pgs_runtime.scheduler import run_wf # lazy import — avoids circular dependency
199
+ # Compiler tokenizes nested dict string values to int addresses.
200
+ # CS_WORKFLOW_LOOP_V0 receives int addrs from the compiled mapping; resolve to FQDN here.
201
+ if isinstance(wf_fqdn_or_addr, int):
202
+ wf_fqdn_or_addr = pkg.vocab.fqdn(wf_fqdn_or_addr)
203
+ return run_wf(wf_fqdn_or_addr, payload, pkg, writer, data_root)
204
+ return executor
205
+
206
+
180
207
  def _execute_cs_step(
181
208
  cs_addr: int,
182
209
  op: str,
@@ -184,6 +211,7 @@ def _execute_cs_step(
184
211
  rb_addr: int,
185
212
  pkg: RuntimePackage,
186
213
  data_root: str,
214
+ wf_executor=None,
187
215
  ) -> tuple[str, dict[str, Any]]:
188
216
  """
189
217
  Execute a CS (side effect) step.
@@ -205,6 +233,11 @@ def _execute_cs_step(
205
233
  policy_raw = policy_entry.get("policy") or {}
206
234
  policy = _expand_policy(policy_raw, data_root)
207
235
 
236
+ # Inject workflow executor for CS types that need nested WF invocation.
237
+ # Injected unconditionally — CSs that don't use it ignore the key.
238
+ if wf_executor is not None:
239
+ policy = {**policy, "workflow_executor": wf_executor}
240
+
208
241
  # Instantiate CS runtime
209
242
  handler_ref = cs_entry["handler_ref"]
210
243
  cs_metadata = cs_entry.get("cs_metadata", {})
@@ -44,10 +44,10 @@ class DispatchTable:
44
44
  """
45
45
  Integer-keyed routing substrate from dispatch.json.
46
46
 
47
- routing: {wf_addr: {cc_addr: {condition_addr: next_cc_addr}}}
47
+ routing: {wf_addr: {cc_addr: {condition_addr: {"addr": next_cc_addr, "key": node_key}}}}
48
48
  pipeline: {cc_addr: [step, ...]}
49
- entry: {wf_addr: {"start": cc_addr, "rb": rb_addr, "in": in_addr}}
50
- bindings: {wf_addr: {cc_addr: {input_name: path_or_literal}}}
49
+ entry: {wf_addr: {"start": cc_addr, "start_key": node_key, "rb": rb_addr, "in": in_addr}}
50
+ bindings: {wf_addr: {node_key: {input_name: path_or_literal}}}
51
51
 
52
52
  Each pipeline step is a named-field execution instruction record:
53
53
  {
@@ -59,14 +59,19 @@ class DispatchTable:
59
59
  "step_id": str, # symbolic step name for $.results.<step_id>.<field> references
60
60
  }
61
61
 
62
+ Routing values carry both the next CC address and the target node_key so that
63
+ the scheduler can disambiguate distinct WF usages of the same shared CC
64
+ (e.g. four denial audit nodes that all bind to CC_RECORD_DENIED_ACTION_V0).
65
+
66
+ Bindings are keyed by node_key (not CC address) for the same reason.
67
+
62
68
  All semantics are compiler-materialized. The dispatcher is a blind executor.
63
- All dict keys are ints. All address values are ints.
64
69
  The nested dicts are plain Python dicts (not frozen) — callers must not mutate.
65
70
  """
66
- routing: dict[int, dict[int, dict[int, int]]]
71
+ routing: dict[int, dict[int, dict[int, Any]]]
67
72
  pipeline: dict[int, list[dict]]
68
- entry: dict[int, dict[str, int]]
69
- bindings: dict[int, dict[int, dict[str, Any]]]
73
+ entry: dict[int, dict[str, Any]]
74
+ bindings: dict[int, dict[str, dict[str, Any]]]
70
75
 
71
76
 
72
77
  @dataclass(frozen=True)
@@ -209,14 +214,16 @@ def _build_dispatch(raw: dict) -> DispatchTable:
209
214
  """
210
215
  Parse dispatch.json into integer-keyed DispatchTable.
211
216
 
212
- JSON keys are strings (JSON spec). Addresses are hex-string or int values.
213
- All converted to int on load.
217
+ JSON keys are strings (JSON spec). Addresses are int values.
218
+ Outer WF and CC keys are converted to int. Routing values are
219
+ {"addr": int, "key": str} dicts — preserved as-is. Bindings keys
220
+ are node_key strings — preserved as-is (not int-converted).
214
221
  """
215
- routing: dict[int, dict[int, dict[int, int]]] = {}
222
+ routing: dict[int, dict[int, dict[int, Any]]] = {}
216
223
  for wf_key, cc_map in raw.get("routing", {}).items():
217
224
  wf_addr = int(wf_key)
218
225
  routing[wf_addr] = {
219
- int(cc_key): {int(cond): int(tgt) for cond, tgt in cond_map.items()}
226
+ int(cc_key): {int(cond): tgt for cond, tgt in cond_map.items()}
220
227
  for cc_key, cond_map in cc_map.items()
221
228
  }
222
229
 
@@ -225,15 +232,20 @@ def _build_dispatch(raw: dict) -> DispatchTable:
225
232
  cc_addr = int(cc_key)
226
233
  pipeline[cc_addr] = steps # list of named-field step dicts
227
234
 
228
- entry: dict[int, dict[str, int]] = {}
235
+ entry: dict[int, dict[str, Any]] = {}
229
236
  for wf_key, e in raw.get("entry", {}).items():
230
237
  wf_addr = int(wf_key)
231
- entry[wf_addr] = {k: int(v) for k, v in e.items()}
238
+ # int-convert only numeric values (start, rb, in); leave start_key as str
239
+ entry[wf_addr] = {
240
+ k: (int(v) if isinstance(v, (int, float)) else v)
241
+ for k, v in e.items()
242
+ }
232
243
 
233
- bindings: dict[int, dict[int, dict[str, Any]]] = {}
234
- for wf_key, cc_map in raw.get("bindings", {}).items():
244
+ bindings: dict[int, dict[str, dict[str, Any]]] = {}
245
+ for wf_key, node_map in raw.get("bindings", {}).items():
235
246
  wf_addr = int(wf_key)
236
- bindings[wf_addr] = {int(cc_key): inp for cc_key, inp in cc_map.items()}
247
+ # node_map keys are node_key strings (e.g. "CC_NORMALIZE_AGENT_REQUEST_V0")
248
+ bindings[wf_addr] = {node_key: inp for node_key, inp in node_map.items()}
237
249
 
238
250
  return DispatchTable(
239
251
  routing = routing,
@@ -90,6 +90,7 @@ def run_wf(
90
90
 
91
91
  rb_addr = entry["rb"]
92
92
  current_addr: int | None = entry["start"]
93
+ current_node_key: str = entry.get("start_key", "")
93
94
 
94
95
  ctx = ExecutionContext(payload)
95
96
  writer.wf_start(payload)
@@ -107,11 +108,14 @@ def run_wf(
107
108
  hops += 1
108
109
 
109
110
  if current_addr in pkg.dispatch.pipeline:
110
- # CC node — resolve WF-level bindings and execute
111
+ # CC node — resolve WF-level bindings and execute.
112
+ # Bindings are keyed by node_key (not CC addr) so that distinct WF
113
+ # usages of the same CC (e.g. four denial audit nodes) each carry
114
+ # their own literal inputs (e.g. different denial_reason values).
111
115
  wf_bindings = (
112
116
  pkg.dispatch.bindings
113
117
  .get(wf_addr, {})
114
- .get(current_addr, {})
118
+ .get(current_node_key, {})
115
119
  )
116
120
  cc_inputs = ctx.resolve_inputs(wf_bindings)
117
121
 
@@ -125,10 +129,18 @@ def run_wf(
125
129
  # admission_snapshot not yet integrated; IN_ nodes pass as ACK
126
130
  result_status = "ACK"
127
131
 
128
- # Resolve result_status → condition address and route to next node
132
+ # Resolve result_status → condition address and route to next node.
133
+ # Routing values are {"addr": int, "key": str} — addr is the next CC address,
134
+ # key is the next node_key for bindings disambiguation.
129
135
  condition_addr = _condition_addr(result_status, pkg)
130
136
  routing = pkg.dispatch.routing.get(wf_addr, {}).get(current_addr, {})
131
- current_addr = routing.get(condition_addr) # None → terminal
137
+ next_entry = routing.get(condition_addr) # None → terminal
138
+ if isinstance(next_entry, dict):
139
+ current_addr = next_entry.get("addr")
140
+ current_node_key = next_entry.get("key", "")
141
+ else:
142
+ current_addr = next_entry # bare int (legacy) or None
143
+ current_node_key = ""
132
144
 
133
145
  writer.wf_complete(result_status)
134
146
  return result_status, surface
@@ -1,8 +1,8 @@
1
1
  """
2
- trace_viz.py — Evidence projection: execution path visualization.
2
+ trace_viz.py — Evidence projection: execution path behavior logic.
3
3
 
4
4
  Reads a completed trace (.jsonl) and the compiled workflow graph (.graph.json)
5
- from the protocol snapshot visualization directory, then generates a PNG with
5
+ from the protocol snapshot behavior_logic directory, then generates a PNG with
6
6
  the actual execution path overlaid in red on the static compiled graph.
7
7
 
8
8
  This is Evidence Projection — not a runtime execution feature:
@@ -14,7 +14,7 @@ This is Evidence Projection — not a runtime execution feature:
14
14
  → execution path PNG
15
15
 
16
16
  Inputs (both already materialized, read-only):
17
- protocol_snapshot/visualization/<WF_CODE>/<WF_CODE>.graph.json
17
+ protocol_snapshot/behavior_logic/<WF_CODE>/<WF_CODE>.graph.json
18
18
  traces/<domain>/<wf_code>/<trace_id>/<trace_id>.jsonl
19
19
 
20
20
  Output:
@@ -22,10 +22,10 @@ Output:
22
22
 
23
23
  Architectural invariant:
24
24
  This module reads ONLY from:
25
- - protocol_snapshot/visualization/ (compiled graph artifacts)
25
+ - protocol_snapshot/behavior_logic/ (compiled graph artifacts)
26
26
  - the caller-supplied trace .jsonl (execution evidence)
27
27
  It does NOT walk protocol_snapshot/artifacts/ or any other canonical
28
- protocol location. Visualization is overlay-only — no protocol interpretation.
28
+ protocol location. Behavior logic overlay is read-only — no protocol interpretation.
29
29
 
30
30
  Uses graphviz (dot) — returns None silently if dot is not available.
31
31
  """
@@ -84,18 +84,18 @@ def render_trace_png(
84
84
  wf_fqdn = wf_start["detail"]["wf_fqdn"]
85
85
  wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_REGISTER_ACTOR_UNVERIFIED_V0"
86
86
 
87
- # Load compiled graph from protocol_snapshot/visualization/
87
+ # Load compiled graph from protocol_snapshot/behavior_logic/
88
88
  graph_path = (
89
89
  workspace
90
90
  / "protocol_snapshot"
91
- / "visualization"
91
+ / "behavior_logic"
92
92
  / wf_code
93
93
  / f"{wf_code}.graph.json"
94
94
  )
95
95
  if not graph_path.exists():
96
96
  raise FileNotFoundError(
97
97
  f"Compiled graph not found: {graph_path}\n"
98
- f"Re-run the compiler to regenerate visualization artifacts."
98
+ f"Re-run the compiler to regenerate behavior_logic artifacts."
99
99
  )
100
100
 
101
101
  graph = json.loads(graph_path.read_text(encoding="utf-8"))
@@ -162,7 +162,19 @@ def _extract_execution_path(
162
162
  for e in graph["edges"]
163
163
  }
164
164
 
165
- cc_completions = [e for e in events if e["event_type"] == "CC_COMPLETE"]
165
+ # Filter to top-level workflow CC events only.
166
+ # Sub-workflows emit CC_COMPLETE events with the same wf_addr as the
167
+ # top-level workflow (the runtime reuses the same address), so wf_addr
168
+ # filtering is insufficient. Instead, restrict to CCs that are declared
169
+ # nodes in the top-level graph — sub-workflow CCs won't appear there.
170
+ graph_cc_nodes: set[str] = {
171
+ n["id"] for n in graph["nodes"] if n["type"] == "CC"
172
+ }
173
+ cc_completions = [
174
+ e for e in events
175
+ if e["event_type"] == "CC_COMPLETE"
176
+ and e["detail"]["cc_fqdn"].split("::")[-1] in graph_cc_nodes
177
+ ]
166
178
 
167
179
  if not cc_completions:
168
180
  # No CC nodes executed — IN_ gated as NACK and routed to EXIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgs_runtime
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: PGS runtime for Protocol-Governed Systems
5
5
  Author: Bachi
6
6
  License-Expression: Apache-2.0
@@ -29,7 +29,7 @@ It does not discover behavior. It does not interpret intent. It does not contain
29
29
 
30
30
  Behavior is declared in protocol, executed by runtime, implemented in capabilities, and observed via traces and state.
31
31
 
32
- > **New to PGS?** This is one of eight repositories in the Protocol-Governed Systems ecosystem.
32
+ > **New to PGS?** This is one of the repositories in the Protocol-Governed Systems ecosystem.
33
33
  > For orientation, architecture overview, and end-to-end execution, start at [pgs_workspace](https://github.com/bachipeachy/pgs_workspace).
34
34
 
35
35
  ---
@@ -172,6 +172,7 @@ PGS is intentionally split into layers:
172
172
  | **Execution** | **`pgs_runtime` ← here** | **Traverse compiled graph deterministically** |
173
173
  | Capabilities | `pgs_capabilities` | Provide CT/CS implementations |
174
174
  | Domains | `pgs_blockchain`, `pgs_ai_governance` | Real-world workflows |
175
+ | Change Mgmt | `pgs_change_mgmt` | Governed SDLC — Change Request to Authoring Mandate (new in v0.5.0) |
175
176
  | Entry point | `pgs_workspace` | Run and observe |
176
177
 
177
178
  ---
@@ -4,6 +4,7 @@ NOTICE
4
4
  README.md
5
5
  pyproject.toml
6
6
  pgs_runtime/__init__.py
7
+ pgs_runtime/api.py
7
8
  pgs_runtime/cli.py
8
9
  pgs_runtime/conformance.py
9
10
  pgs_runtime/ct_errors.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pgs_runtime"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "PGS runtime for Protocol-Governed Systems"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes