programgarden 2.6.4__tar.gz → 2.6.6__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.4 → programgarden-2.6.6}/PKG-INFO +16 -5
  2. {programgarden-2.6.4 → programgarden-2.6.6}/README.md +13 -2
  3. programgarden-2.6.6/programgarden/binding_provenance.py +181 -0
  4. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/context.py +29 -0
  5. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/executor.py +277 -30
  6. programgarden-2.6.6/programgarden/futures_exchange.py +137 -0
  7. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/node_runner.py +2 -1
  8. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/providers/llm_provider.py +17 -1
  9. programgarden-2.6.6/programgarden/ranking_nodes.py +478 -0
  10. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resolver.py +45 -0
  11. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/validation_replay.py +9 -2
  12. {programgarden-2.6.4 → programgarden-2.6.6}/pyproject.toml +3 -3
  13. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/__init__.py +0 -0
  14. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/binding_validator.py +0 -0
  15. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/client.py +0 -0
  16. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/code_worker.py +0 -0
  17. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/__init__.py +0 -0
  18. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/adjustment_delivery.py +0 -0
  19. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/broker_evidence.py +0 -0
  20. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/broker_order_totals.py +0 -0
  21. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/broker_snapshot.py +0 -0
  22. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/checkpoint_manager.py +0 -0
  23. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/db_naming.py +0 -0
  24. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/execution_storage.py +0 -0
  25. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/fill_reconciler.py +0 -0
  26. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/order_cancellation.py +0 -0
  27. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/order_recovery.py +0 -0
  28. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/owned_order_cancellation.py +0 -0
  29. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/position_reconciliation.py +0 -0
  30. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/query_builder.py +0 -0
  31. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/sqlite_scope.py +0 -0
  32. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/workflow_position_tracker.py +0 -0
  33. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/database/workflow_risk_tracker.py +0 -0
  34. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/deep_fixtures.py +0 -0
  35. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/futures_contract_info.py +0 -0
  36. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/futures_history.py +0 -0
  37. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/futures_orderable.py +0 -0
  38. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/futures_pnl.py +0 -0
  39. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/futures_read_evidence.py +0 -0
  40. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/incremental_build.py +0 -0
  41. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/managed_order_control.py +0 -0
  42. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/order_lifecycle.py +0 -0
  43. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/paper_futures_business_date.py +0 -0
  44. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/plugin/__init__.py +0 -0
  45. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/plugin/sandbox.py +0 -0
  46. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/providers/__init__.py +0 -0
  47. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/providers/llm_errors.py +0 -0
  48. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/reconnect_handler.py +0 -0
  49. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_contracts.py +0 -0
  50. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_events.py +0 -0
  51. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_external.py +0 -0
  52. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_order_adapter.py +0 -0
  53. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_orders.py +0 -0
  54. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_scenarios.py +0 -0
  55. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_semantics.py +0 -0
  56. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_sources.py +0 -0
  57. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_sqlite.py +0 -0
  58. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/replay_triggers.py +0 -0
  59. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resource/__init__.py +0 -0
  60. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resource/context.py +0 -0
  61. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resource/limiter.py +0 -0
  62. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resource/monitor.py +0 -0
  63. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/resource/throttle.py +0 -0
  64. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/semantic_rules.py +0 -0
  65. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/__init__.py +0 -0
  66. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/credential_tools.py +0 -0
  67. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/definition_tools.py +0 -0
  68. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/event_tools.py +0 -0
  69. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/job_tools.py +0 -0
  70. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/registry_tools.py +0 -0
  71. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/tools/sqlite_tools.py +0 -0
  72. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/validation_recommender.py +0 -0
  73. {programgarden-2.6.4 → programgarden-2.6.6}/programgarden/valuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: programgarden
3
- Version: 2.6.4
3
+ Version: 2.6.6
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.3,<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,17 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
31
31
 
32
32
  # ProgramGarden
33
33
 
34
+ The 2.6.6 release candidate [ranking nodes and runtime-values-as-data correction](../../docs/release-2.6.6-validation.md)
35
+ adds KoreaStockRankingNode and OverseasStockRankingNode, evaluates only
36
+ expression templates authored in the workflow, routes Gemini and DeepSeek
37
+ credentials to their own APIs, and reads an unresolved futures position
38
+ exchange again at most once per 5 minutes per contract instead of keeping it
39
+ empty for the whole run.
40
+
41
+ The 2.6.5 release candidate [futures position exchange correction](../../docs/futures-position-exchange.md)
42
+ reads each futures position's and pending order's exchange from o3105 instead
43
+ of a fixed `HKEX`; an unconfirmed exchange stays empty.
44
+
34
45
  The 2.6.4 release candidate [paper futures account correction](../../docs/paper-futures-business-date.md#account-node-and-rejection-messages-264-candidate)
35
46
  returns an empty futures account for the paper empty-account answer and names
36
47
  the broker message in unaccepted response errors. Live behavior is unchanged.
@@ -303,7 +314,7 @@ job = await pg.run_async(
303
314
 
304
315
  ## 주요 특징
305
316
 
306
- - **노드 기반 DSL**: 72개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
317
+ - **노드 기반 DSL**: 74개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
307
318
  - **실시간 처리**: WebSocket 기반 실시간 시세, 계좌, 체결 이벤트 수신
308
319
  - **AI Agent 통합**: LLMModelNode + AIAgentNode로 LLM 기반 분석/의사결정
309
320
  - **플러그인 확장**: 67개 내장 전략 플러그인 (RSI, MACD, 볼린저밴드, 이치모쿠, 듀얼모멘텀, 터틀브레이크아웃 등)
@@ -315,7 +326,7 @@ job = await pg.run_async(
315
326
 
316
327
  ```
317
328
  5-Layer Architecture:
318
- 1. Registry Layer - 노드/플러그인 메타데이터 (73개 노드, 77개 플러그인)
329
+ 1. Registry Layer - 노드/플러그인 메타데이터 (76개 노드, 77개 플러그인)
319
330
  2. Credential Layer - 인증 정보 관리
320
331
  3. Definition Layer - JSON 워크플로우 정의 (노드, 엣지, 크레덴셜)
321
332
  4. Job Layer - 상태 유지 실행 인스턴스 (최대 24시간 장기 실행)
@@ -2,6 +2,17 @@ The 2.6.2 release candidate [futures minute alias correction](../../docs/futures
2
2
 
3
3
  # ProgramGarden
4
4
 
5
+ The 2.6.6 release candidate [ranking nodes and runtime-values-as-data correction](../../docs/release-2.6.6-validation.md)
6
+ adds KoreaStockRankingNode and OverseasStockRankingNode, evaluates only
7
+ expression templates authored in the workflow, routes Gemini and DeepSeek
8
+ credentials to their own APIs, and reads an unresolved futures position
9
+ exchange again at most once per 5 minutes per contract instead of keeping it
10
+ empty for the whole run.
11
+
12
+ The 2.6.5 release candidate [futures position exchange correction](../../docs/futures-position-exchange.md)
13
+ reads each futures position's and pending order's exchange from o3105 instead
14
+ of a fixed `HKEX`; an unconfirmed exchange stays empty.
15
+
5
16
  The 2.6.4 release candidate [paper futures account correction](../../docs/paper-futures-business-date.md#account-node-and-rejection-messages-264-candidate)
6
17
  returns an empty futures account for the paper empty-account answer and names
7
18
  the broker message in unaccepted response errors. Live behavior is unchanged.
@@ -274,7 +285,7 @@ job = await pg.run_async(
274
285
 
275
286
  ## 주요 특징
276
287
 
277
- - **노드 기반 DSL**: 72개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
288
+ - **노드 기반 DSL**: 74개 내장 노드를 조합하여 코딩 없이 자동매매 전략 구성
278
289
  - **실시간 처리**: WebSocket 기반 실시간 시세, 계좌, 체결 이벤트 수신
279
290
  - **AI Agent 통합**: LLMModelNode + AIAgentNode로 LLM 기반 분석/의사결정
280
291
  - **플러그인 확장**: 67개 내장 전략 플러그인 (RSI, MACD, 볼린저밴드, 이치모쿠, 듀얼모멘텀, 터틀브레이크아웃 등)
@@ -286,7 +297,7 @@ job = await pg.run_async(
286
297
 
287
298
  ```
288
299
  5-Layer Architecture:
289
- 1. Registry Layer - 노드/플러그인 메타데이터 (73개 노드, 77개 플러그인)
300
+ 1. Registry Layer - 노드/플러그인 메타데이터 (76개 노드, 77개 플러그인)
290
301
  2. Credential Layer - 인증 정보 관리
291
302
  3. Definition Layer - JSON 워크플로우 정의 (노드, 엣지, 크레덴셜)
292
303
  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)
@@ -285,6 +285,9 @@ class ExecutionContext:
285
285
  # Tracks workflow positions separately using FIFO for competition ranking
286
286
  self._workflow_position_tracker: Optional[Any] = None # WorkflowPositionTracker (lazy init)
287
287
  self._personal_metrics_cache: Optional[Tuple[tuple, float, dict]] = None
288
+ # Per-run o3105 exchange answers for futures account/pending rows
289
+ # (programgarden.futures_exchange creates it on first use).
290
+ self._futures_exchange_cache: Optional[Any] = None
288
291
  self._workflow_risk_tracker: Optional[Any] = None # WorkflowRiskTracker (lazy init)
289
292
  self._workflow_broker_node_id: Optional[str] = None # BrokerNode ID for PnL refresh
290
293
  self._workflow_product: str = "overseas_stock" # Product type for PnL refresh
@@ -328,8 +331,34 @@ class ExecutionContext:
328
331
  # warn-and-continue behaviour untouched).
329
332
  self._deep_unresolved_bindings: List[Dict[str, str]] = []
330
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
+
331
339
  self._build_dag_index(workflow_edges, workflow_nodes)
332
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
+
333
362
  # === Storage Directory ===
334
363
 
335
364
  def _resolve_db_path(self, db_filename: str) -> str: