programgarden 2.6.5__tar.gz → 2.6.7__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 (73) hide show
  1. {programgarden-2.6.5 → programgarden-2.6.7}/PKG-INFO +24 -6
  2. {programgarden-2.6.5 → programgarden-2.6.7}/README.md +21 -3
  3. programgarden-2.6.7/programgarden/binding_provenance.py +181 -0
  4. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/context.py +26 -0
  5. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/executor.py +384 -74
  6. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_exchange.py +53 -18
  7. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/node_runner.py +2 -1
  8. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/providers/llm_provider.py +17 -1
  9. programgarden-2.6.7/programgarden/ranking_nodes.py +478 -0
  10. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resolver.py +45 -0
  11. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/validation_replay.py +9 -2
  12. {programgarden-2.6.5 → programgarden-2.6.7}/pyproject.toml +3 -3
  13. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/__init__.py +0 -0
  14. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/binding_validator.py +0 -0
  15. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/client.py +0 -0
  16. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/code_worker.py +0 -0
  17. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/__init__.py +0 -0
  18. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/adjustment_delivery.py +0 -0
  19. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/broker_evidence.py +0 -0
  20. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/broker_order_totals.py +0 -0
  21. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/broker_snapshot.py +0 -0
  22. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/checkpoint_manager.py +0 -0
  23. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/db_naming.py +0 -0
  24. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/execution_storage.py +0 -0
  25. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/fill_reconciler.py +0 -0
  26. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/order_cancellation.py +0 -0
  27. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/order_recovery.py +0 -0
  28. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/owned_order_cancellation.py +0 -0
  29. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/position_reconciliation.py +0 -0
  30. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/query_builder.py +0 -0
  31. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/sqlite_scope.py +0 -0
  32. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/workflow_position_tracker.py +0 -0
  33. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/deep_fixtures.py +0 -0
  35. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_contract_info.py +0 -0
  36. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_history.py +0 -0
  37. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_orderable.py +0 -0
  38. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_pnl.py +0 -0
  39. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/futures_read_evidence.py +0 -0
  40. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/incremental_build.py +0 -0
  41. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/managed_order_control.py +0 -0
  42. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/order_lifecycle.py +0 -0
  43. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/paper_futures_business_date.py +0 -0
  44. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/plugin/__init__.py +0 -0
  45. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/plugin/sandbox.py +0 -0
  46. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/providers/__init__.py +0 -0
  47. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/providers/llm_errors.py +0 -0
  48. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/reconnect_handler.py +0 -0
  49. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_contracts.py +0 -0
  50. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_events.py +0 -0
  51. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_external.py +0 -0
  52. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_order_adapter.py +0 -0
  53. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_orders.py +0 -0
  54. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_scenarios.py +0 -0
  55. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_semantics.py +0 -0
  56. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_sources.py +0 -0
  57. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_sqlite.py +0 -0
  58. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/replay_triggers.py +0 -0
  59. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resource/__init__.py +0 -0
  60. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resource/context.py +0 -0
  61. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resource/limiter.py +0 -0
  62. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resource/monitor.py +0 -0
  63. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/resource/throttle.py +0 -0
  64. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/semantic_rules.py +0 -0
  65. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/__init__.py +0 -0
  66. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/credential_tools.py +0 -0
  67. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/definition_tools.py +0 -0
  68. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/event_tools.py +0 -0
  69. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/job_tools.py +0 -0
  70. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/registry_tools.py +0 -0
  71. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/tools/sqlite_tools.py +0 -0
  72. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/validation_recommender.py +0 -0
  73. {programgarden-2.6.5 → programgarden-2.6.7}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.6.5
3
+ Version: 2.6.7
4
4
  Summary: ProgramGarden - 노드 기반 자동매매 DSL 실행 엔진
5
5
  License-Expression: AGPL-3.0-or-later
6
6
  Author: 프로그램동산
@@ -16,8 +16,8 @@ Requires-Dist: croniter (>=6.0.0,<7.0.0)
16
16
  Requires-Dist: litellm (>=1.40.0)
17
17
  Requires-Dist: lxml (>=6.0.2,<7.0.0)
18
18
  Requires-Dist: programgarden-community (>=2.3.1,<3.0.0)
19
- Requires-Dist: programgarden-core (>=2.6.2,<3.0.0)
20
- Requires-Dist: programgarden-finance (>=2.0.2,<3.0.0)
19
+ Requires-Dist: programgarden-core (>=2.6.3,<3.0.0)
20
+ Requires-Dist: programgarden-finance (>=2.0.5,<3.0.0)
21
21
  Requires-Dist: psutil (>=6.0.0,<7.0.0)
22
22
  Requires-Dist: psycopg2-binary (>=2.9.11,<3.0.0)
23
23
  Requires-Dist: pydantic (>=2.0.1,<3.0.0)
@@ -31,6 +31,20 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
31
31
 
32
32
  # ProgramGarden
33
33
 
34
+ The 2.6.7 release [futures TC2 confirmation handling](../../docs/release-2.6.7-validation.md)
35
+ requires programgarden-finance 2.0.5, which reads overseas futures modify and
36
+ cancel confirmations (TC2) as LS sends them and re-registers account events
37
+ after a reconnect. The engine finds the workflow's order through
38
+ `orgn_ordr_no`, uses the guide's side codes and skips frames the SDK could
39
+ not read.
40
+
41
+ The 2.6.6 release candidate [ranking nodes and runtime-values-as-data correction](../../docs/release-2.6.6-validation.md)
42
+ adds KoreaStockRankingNode and OverseasStockRankingNode, evaluates only
43
+ expression templates authored in the workflow, routes Gemini and DeepSeek
44
+ credentials to their own APIs, and reads an unresolved futures position
45
+ exchange again at most once per 5 minutes per contract instead of keeping it
46
+ empty for the whole run.
47
+
34
48
  The 2.6.5 release candidate [futures position exchange correction](../../docs/futures-position-exchange.md)
35
49
  reads each futures position's and pending order's exchange from o3105 instead
36
50
  of a fixed `HKEX`; an unconfirmed exchange stays empty.
@@ -198,7 +212,11 @@ exchange execution numbers and broker execution numbers are not interchangeable.
198
212
  This ledger API alone does not reconcile finite workflows after their shutdown.
199
213
 
200
214
  Standalone futures TC3 callbacks interpret `s_b_ccd="1"` as sell and `"2"` as buy,
201
- matching the SDK contract. Unknown or blank side codes are logged and skipped
215
+ matching the SDK contract. TC2 confirmations use the same codes and find the
216
+ workflow's order through `orgn_ordr_no`: a futures modify or cancel gets its own
217
+ new order number (LS paper, 2026-10-15), so the cancel confirmation cancels the
218
+ original order in the ledger and a modify confirmation updates the successor row
219
+ recorded by ModifyOrderNodeExecutor. Unknown or blank side codes are logged and skipped
202
220
  before any inventory write is scheduled. With an app order lifecycle handler,
203
221
  TC3 still bypasses this legacy fill path: canonical REST reconciliation remains
204
222
  the sole writer. This side correction does not establish an alias between TC3
@@ -307,7 +325,7 @@ job = await pg.run_async(
307
325
 
308
326
  ## 주요 특징
309
327
 
310
- - **노드 기반 DSL**: 72개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
328
+ - **노드 기반 DSL**: 74개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
311
329
  - **실시간 처리**: WebSocket 기반 실시간 시세, 계좌, 체결 이벤트 수신
312
330
  - **AI Agent 통합**: LLMModelNode + AIAgentNode로 LLM 기반 분석/의사결정
313
331
  - **플러그인 확장**: 67개 내장 전략 플러그인 (RSI, MACD, 볼린저밴드, 이치모쿠, 듀얼모멘텀, 터틀브레이크아웃 등)
@@ -319,7 +337,7 @@ job = await pg.run_async(
319
337
 
320
338
  ```
321
339
  5-Layer Architecture:
322
- 1. Registry Layer - 노드/플러그인 메타데이터 (73개 노드, 77개 플러그인)
340
+ 1. Registry Layer - 노드/플러그인 메타데이터 (76개 노드, 77개 플러그인)
323
341
  2. Credential Layer - 인증 정보 관리
324
342
  3. Definition Layer - JSON 워크플로우 정의 (노드, 엣지, 크레덴셜)
325
343
  4. Job Layer - 상태 유지 실행 인스턴스 (최대 24시간 장기 실행)
@@ -2,6 +2,20 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
2
2
 
3
3
  # ProgramGarden
4
4
 
5
+ The 2.6.7 release [futures TC2 confirmation handling](../../docs/release-2.6.7-validation.md)
6
+ requires programgarden-finance 2.0.5, which reads overseas futures modify and
7
+ cancel confirmations (TC2) as LS sends them and re-registers account events
8
+ after a reconnect. The engine finds the workflow's order through
9
+ `orgn_ordr_no`, uses the guide's side codes and skips frames the SDK could
10
+ not read.
11
+
12
+ The 2.6.6 release candidate [ranking nodes and runtime-values-as-data correction](../../docs/release-2.6.6-validation.md)
13
+ adds KoreaStockRankingNode and OverseasStockRankingNode, evaluates only
14
+ expression templates authored in the workflow, routes Gemini and DeepSeek
15
+ credentials to their own APIs, and reads an unresolved futures position
16
+ exchange again at most once per 5 minutes per contract instead of keeping it
17
+ empty for the whole run.
18
+
5
19
  The 2.6.5 release candidate [futures position exchange correction](../../docs/futures-position-exchange.md)
6
20
  reads each futures position's and pending order's exchange from o3105 instead
7
21
  of a fixed `HKEX`; an unconfirmed exchange stays empty.
@@ -169,7 +183,11 @@ exchange execution numbers and broker execution numbers are not interchangeable.
169
183
  This ledger API alone does not reconcile finite workflows after their shutdown.
170
184
 
171
185
  Standalone futures TC3 callbacks interpret `s_b_ccd="1"` as sell and `"2"` as buy,
172
- matching the SDK contract. Unknown or blank side codes are logged and skipped
186
+ matching the SDK contract. TC2 confirmations use the same codes and find the
187
+ workflow's order through `orgn_ordr_no`: a futures modify or cancel gets its own
188
+ new order number (LS paper, 2026-10-15), so the cancel confirmation cancels the
189
+ original order in the ledger and a modify confirmation updates the successor row
190
+ recorded by ModifyOrderNodeExecutor. Unknown or blank side codes are logged and skipped
173
191
  before any inventory write is scheduled. With an app order lifecycle handler,
174
192
  TC3 still bypasses this legacy fill path: canonical REST reconciliation remains
175
193
  the sole writer. This side correction does not establish an alias between TC3
@@ -278,7 +296,7 @@ job = await pg.run_async(
278
296
 
279
297
  ## 주요 특징
280
298
 
281
- - **노드 기반 DSL**: 72개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
299
+ - **노드 기반 DSL**: 74개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
282
300
  - **실시간 처리**: WebSocket 기반 실시간 시세, 계좌, 체결 이벤트 수신
283
301
  - **AI Agent 통합**: LLMModelNode + AIAgentNode로 LLM 기반 분석/의사결정
284
302
  - **플러그인 확장**: 67개 내장 전략 플러그인 (RSI, MACD, 볼린저밴드, 이치모쿠, 듀얼모멘텀, 터틀브레이크아웃 등)
@@ -290,7 +308,7 @@ job = await pg.run_async(
290
308
 
291
309
  ```
292
310
  5-Layer Architecture:
293
- 1. Registry Layer - 노드/플러그인 메타데이터 (73개 노드, 77개 플러그인)
311
+ 1. Registry Layer - 노드/플러그인 메타데이터 (76개 노드, 77개 플러그인)
294
312
  2. Credential Layer - 인증 정보 관리
295
313
  3. Definition Layer - JSON 워크플로우 정의 (노드, 엣지, 크레덴셜)
296
314
  4. Job Layer - 상태 유지 실행 인스턴스 (최대 24시간 장기 실행)
@@ -0,0 +1,181 @@
1
+ """Expressions run only from authored templates; runtime values are data.
2
+
3
+ Before 2.6.6 a node's config was evaluated twice: the workflow job resolved
4
+ ``{{ … }}`` bindings (``WorkflowJob._resolve_config_expressions``) and the node
5
+ executor then evaluated the already-resolved config again
6
+ (``evaluate_all_bindings``). Any value produced at runtime that contained
7
+ ``{{`` — LLM text, HTTP bodies, broker strings, workflow inputs, credentials —
8
+ was evaluated a second time in the workflow expression context.
9
+
10
+ Invariant: an expression is evaluated only if its template text was authored
11
+ in the workflow definition (the node config as stored in the DSL), and at most
12
+ once per execution of that node (per iteration for deferred ``{{ item }}``
13
+ bindings). The check is positional: a string at config path *P* is evaluated
14
+ only while it is still exactly the authored string at *P*. Once a template has
15
+ been resolved, the value at *P* is data and passes byte-identical.
16
+
17
+ Each workflow job registers an authored snapshot per node
18
+ (``ExecutionContext.register_authored_node_configs``). Without a snapshot (a
19
+ node run outside a workflow job, e.g. ``NodeRunner``) the engine keeps the
20
+ legacy behaviour and evaluates every template it is given, because that config
21
+ is the caller's authored input.
22
+
23
+ Source-code fields are never evaluated (``NEVER_EVALUATED_KEYS``).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import copy
29
+ from typing import Any, Dict, Optional
30
+
31
+ # A position the authored config does not have (engine-injected keys, runtime data).
32
+ NOT_AUTHORED = object()
33
+ # No authored snapshot for this node: evaluate every template (pre-2.6.6 behaviour).
34
+ LEGACY = object()
35
+
36
+ # Fields that hold source code, not templates. They are never evaluated, even
37
+ # when the text contains ``{{ … }}``.
38
+ NEVER_EVALUATED_KEYS: Dict[str, frozenset] = {
39
+ "CodeNode": frozenset({"code"}),
40
+ }
41
+
42
+
43
+ def authored_snapshot(node_type: str, config: Optional[Dict[str, Any]], fields: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
44
+ """Deep copy of a node's authored config, as evaluation positions see it.
45
+
46
+ Plugin ``fields`` are exposed under the ``fields`` key (executors receive
47
+ them there). Never-evaluated keys are removed so their positions read as
48
+ not authored.
49
+ """
50
+ snapshot = copy.deepcopy(dict(config or {}))
51
+ if fields and "fields" not in snapshot:
52
+ snapshot["fields"] = copy.deepcopy(fields)
53
+ for key in NEVER_EVALUATED_KEYS.get(node_type, ()):
54
+ snapshot.pop(key, None)
55
+ return snapshot
56
+
57
+
58
+ def authored_root(context: Any, node_id: Optional[str]) -> Any:
59
+ """The authored snapshot of ``node_id``, or ``LEGACY`` when none is registered."""
60
+ if node_id is None:
61
+ return LEGACY
62
+ getter = getattr(context, "authored_node_config", None)
63
+ if not callable(getter):
64
+ return LEGACY
65
+ try:
66
+ snapshot = getter(node_id)
67
+ except Exception: # pragma: no cover - defensive (mock contexts)
68
+ return LEGACY
69
+ return snapshot if isinstance(snapshot, dict) else LEGACY
70
+
71
+
72
+ def child(authored: Any, key: Any) -> Any:
73
+ """Authored value at ``key`` of a dict position."""
74
+ if authored is LEGACY:
75
+ return LEGACY
76
+ if isinstance(authored, dict):
77
+ return authored.get(key, NOT_AUTHORED)
78
+ return NOT_AUTHORED
79
+
80
+
81
+ def element(authored: Any, index: int, length: int) -> Any:
82
+ """Authored value at ``index`` of a list position of ``length`` items."""
83
+ if authored is LEGACY:
84
+ return LEGACY
85
+ if isinstance(authored, list) and len(authored) == length:
86
+ return authored[index]
87
+ return NOT_AUTHORED
88
+
89
+
90
+ def authored_at(context: Any, node_id: Optional[str], *path: Any) -> Any:
91
+ """Authored value at a top-level path of ``node_id`` (``LEGACY`` without a snapshot)."""
92
+ authored = authored_root(context, node_id)
93
+ for key in path:
94
+ authored = child(authored, key)
95
+ return authored
96
+
97
+
98
+ def may_evaluate(value: Any, authored: Any) -> bool:
99
+ """True when ``value`` is a template the author wrote at this position, unchanged."""
100
+ if not isinstance(value, str):
101
+ return False
102
+ if authored is LEGACY:
103
+ return True
104
+ return isinstance(authored, str) and value == authored
105
+
106
+
107
+ def copy_containers(value: Any) -> Any:
108
+ """Rebuild dicts/lists (leaves shared) without evaluating anything."""
109
+ if isinstance(value, dict):
110
+ return {k: copy_containers(v) for k, v in value.items()}
111
+ if isinstance(value, list):
112
+ return [copy_containers(v) for v in value]
113
+ return value
114
+
115
+
116
+ class _Literal:
117
+ """Opaque wrapper that hides runtime data from ``ExpressionEvaluator.evaluate_fields``."""
118
+
119
+ __slots__ = ("value",)
120
+
121
+ def __init__(self, value: Any) -> None:
122
+ self.value = value
123
+
124
+
125
+ def shield(value: Any, authored: Any) -> Any:
126
+ """Wrap every non-authored position so ``evaluate_fields`` leaves it untouched."""
127
+ if authored is LEGACY:
128
+ return value
129
+ if isinstance(value, str):
130
+ if "{{" in value and not may_evaluate(value, authored):
131
+ return _Literal(value)
132
+ return value
133
+ if isinstance(value, dict):
134
+ if isinstance(authored, dict):
135
+ return {k: shield(v, authored.get(k, NOT_AUTHORED)) for k, v in value.items()}
136
+ return _Literal(value)
137
+ if isinstance(value, list):
138
+ if isinstance(authored, list) and len(authored) == len(value):
139
+ return [shield(v, a) for v, a in zip(value, authored)]
140
+ return _Literal(value)
141
+ return value
142
+
143
+
144
+ def _rebuild_like_evaluate_fields(value: Any) -> Any:
145
+ # evaluate_fields rebuilds dicts recursively and lists one level deep.
146
+ if isinstance(value, dict):
147
+ return {k: _rebuild_like_evaluate_fields(v) for k, v in value.items()}
148
+ if isinstance(value, list):
149
+ return list(value)
150
+ return value
151
+
152
+
153
+ def unshield(value: Any, authored: Any) -> Any:
154
+ """Undo ``shield`` along the authored structure (resolved data is not walked)."""
155
+ if isinstance(value, _Literal):
156
+ return _rebuild_like_evaluate_fields(value.value)
157
+ if authored is LEGACY:
158
+ return value
159
+ if isinstance(value, dict) and isinstance(authored, dict):
160
+ return {k: unshield(v, authored.get(k, NOT_AUTHORED)) for k, v in value.items()}
161
+ if isinstance(value, list) and isinstance(authored, list) and len(value) == len(authored):
162
+ return [unshield(v, a) for v, a in zip(value, authored)]
163
+ return value
164
+
165
+
166
+ def shield_config(config: Dict[str, Any], authored: Any) -> Dict[str, Any]:
167
+ return {k: shield(v, child(authored, k)) for k, v in config.items()}
168
+
169
+
170
+ def unshield_config(config: Dict[str, Any], authored: Any) -> Dict[str, Any]:
171
+ return {k: unshield(v, child(authored, k)) for k, v in config.items()}
172
+
173
+
174
+ def evaluate_fields_authored(evaluator: Any, fields: Dict[str, Any], authored: Any, on_error: Any = None) -> Dict[str, Any]:
175
+ """``ExpressionEvaluator.evaluate_fields`` limited to authored templates."""
176
+ shielded = shield_config(fields, authored)
177
+ if on_error is None:
178
+ resolved = evaluator.evaluate_fields(shielded)
179
+ else:
180
+ resolved = evaluator.evaluate_fields(shielded, on_error=on_error)
181
+ return unshield_config(resolved, authored)
@@ -331,8 +331,34 @@ class ExecutionContext:
331
331
  # warn-and-continue behaviour untouched).
332
332
  self._deep_unresolved_bindings: List[Dict[str, str]] = []
333
333
 
334
+ # Authored node configs (deep copies of the workflow definition). Only a
335
+ # template still at its authored position is evaluated; everything a node
336
+ # run produces is data. See programgarden.binding_provenance.
337
+ self._authored_node_configs: Dict[str, Dict[str, Any]] = {}
338
+
334
339
  self._build_dag_index(workflow_edges, workflow_nodes)
335
340
 
341
+ # === Authored config (expression provenance) ===
342
+
343
+ def register_authored_node_configs(self, workflow: Any) -> None:
344
+ """Snapshot every node's authored config from a resolved workflow."""
345
+ from programgarden.binding_provenance import authored_snapshot
346
+
347
+ nodes = getattr(workflow, "nodes", None)
348
+ if not isinstance(nodes, dict):
349
+ return
350
+ for node_id, node in nodes.items():
351
+ config = getattr(node, "config", None)
352
+ if not isinstance(config, dict):
353
+ continue
354
+ self._authored_node_configs[node_id] = authored_snapshot(
355
+ getattr(node, "node_type", ""), config, getattr(node, "fields", None),
356
+ )
357
+
358
+ def authored_node_config(self, node_id: str) -> Optional[Dict[str, Any]]:
359
+ """Authored config snapshot of ``node_id``; None when the node has none."""
360
+ return self._authored_node_configs.get(node_id)
361
+
336
362
  # === Storage Directory ===
337
363
 
338
364
  def _resolve_db_path(self, db_filename: str) -> str: