Skip to content

Commit fb1ef70

Browse files
Implement proposal 0045 (nested-lineage augmentation) (#104)
* Implement proposal 0045 (nested-lineage augmentation) Spec proposal 0045 (observability §3.4) rewrites the per-async-context augmentation boundary as three lineage-aware rules: the augmenter's call-stack ancestor chain MUST update (every strict dispatch ancestor on the augmenter's specific path); siblings at any depth MUST NOT; shared parents (fan-out NODE, parallel-branches NODE, invocation span when inside a dispatch boundary) MUST NOT. Engine: tracks per-depth lineage chains parallel to namespace_prefix. descend_into_subgraph extends both chains by None; fan-out instance descent extends fan_out_index_chain with the instance index; pb branch descent extends branch_name_chain with the branch name. Chain ContextVars driven at every leaf-node execution site so set_invocation_metadata sees the full chain. Events: NodeEvent and MetadataAugmentationEvent grow fan_out_index_chain and branch_name_chain fields populated from context. Backwards-compatible — scalar fan_out_index and branch_name fields stay as innermost values. OTel and Langfuse observers: _collect_augmentation_targets and _handle_metadata_augmentation rewrite against the three-step boundary decision tree. Both observers store the chain on _OpenSpan / _OpenObservation so the comparison is local rather than re-derived per event. Shared-parent identification uses the parent_node_name caches structurally — pb cache mirror added to the Langfuse observer. Invocation span update: preserved per fixture 034 (outermost-serial case where the augmenter has no fan-out or pb dispatch on its call-stack path). When inside any dispatch boundary, the invocation span MUST NOT update per §3.4's shared-parent rule. Spec pin v0.36.0 → v0.37.0. Conformance fixture 039 stays deferred in the Langfuse harness — needs runtime-state item-list lookup for nested fan-outs plus a new augment_metadata_from_outer_item directive for case 2. Behavioral contract verified at unit level via test_nested_fan_out_augmentation_reaches_outer_instance_dispatch_span. * Add proposal 0045 entry to conformance manifest CI check_conformance_manifest.py flagged that proposal 0045 (accepted in spec v0.37.0) had no entry in conformance.toml. Add the entry alongside 0044 with status=implemented and the same v0.11.0 ``since`` as the other proposals in the batch.
1 parent 74c6719 commit fb1ef70

17 files changed

Lines changed: 670 additions & 105 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
88

99
### Added
1010

11+
- **Nested-lineage augmentation containment scope** (proposal 0045, observability §3.4, spec v0.37.0). The per-async-context augmentation boundary rewrites as three lineage-aware rules: the augmenter's call-stack ancestor chain MUST update (every strict dispatch ancestor on the path — each outer fan-out instance dispatch span, each outer parallel-branches branch dispatch span, each outer serial subgraph wrapper); siblings at any depth MUST NOT; shared parents (fan-out NODE, parallel-branches NODE, invocation span) MUST NOT. Engine-side: tracks per-depth lineage chains (`fan_out_index_chain` / `branch_name_chain`) parallel to `namespace_prefix`, available on `NodeEvent` and `MetadataAugmentationEvent`. Observer-side: `OTelObserver._collect_augmentation_targets` and `LangfuseObserver._handle_metadata_augmentation` rewrite against the three-step boundary decision tree. Single-level behavior (fixtures 029 / 030 / 034) is unchanged.
1112
- **`LangfuseObserver` Trace input/output sourcing** (proposal 0043, observability §8.4.1). New observer construction knobs populate `trace.input` and `trace.output` per the three-lever decision tree:
1213
- **`disable_state_payload: bool = True`** — privacy knob symmetric to `disable_llm_payload`. When ON (default), Trace fields receive the minimal stub `{entry_node, correlation_id}` / `{final_node, status}`; when OFF, the raw state object is serialized.
1314
- **`trace_input_from_state` / `trace_output_from_state`** — optional caller hooks returning the domain-shaped value to use for `trace.input` / `trace.output`. Returning `None` falls through to the next applicable lever.

conformance.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
[manifest]
3434
implementation = "openarmature-python"
35-
spec_pin = "v0.36.0"
35+
spec_pin = "v0.37.0"
3636

3737
# Status values:
3838
# implemented — shipped behavior matches the proposal's contract
@@ -215,3 +215,15 @@ since = "0.11.0"
215215
[proposals."0044"]
216216
status = "implemented"
217217
since = "0.11.0"
218+
219+
# Spec v0.37.0 (proposal 0045). Engine-side per-depth lineage chains
220+
# + observer-side three-step boundary decision tree implemented.
221+
# Single-level fixtures 029 / 030 / 034 stay unchanged per 0045's
222+
# backward-compat note. Nested-case fixture 039 stays deferred in the
223+
# Langfuse harness — needs runtime-state item-list lookup for nested
224+
# fan-outs plus an ``augment_metadata_from_outer_item`` directive.
225+
# Behavioral contract verified at unit level via
226+
# ``test_nested_fan_out_augmentation_reaches_outer_instance_dispatch_span``.
227+
[proposals."0045"]
228+
status = "implemented"
229+
since = "0.11.0"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
5858
openarmature = "openarmature.cli:main"
5959

6060
[tool.openarmature]
61-
spec_version = "0.36.0"
61+
spec_version = "0.37.0"
6262

6363
[dependency-groups]
6464
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*This is the agent guide bundled with the openarmature Python package, version 0.10.0 (spec v0.36.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.10.0 (spec v0.37.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents
1010

1111
## Capability contracts
1212

13-
_Sourced from openarmature-spec v0.36.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.37.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

1515
### Capability: `graph-engine`
1616

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@
2525
"""
2626

2727
__version__ = "0.10.0"
28-
__spec_version__ = "0.36.0"
28+
__spec_version__ = "0.37.0"

src/openarmature/graph/compiled.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,19 @@
6464
from openarmature.observability.correlation import (
6565
_reset_active_dispatch,
6666
_reset_active_observers,
67+
_reset_branch_name_chain,
6768
_reset_correlation_id,
6869
_reset_fan_out_index,
70+
_reset_fan_out_index_chain,
6971
_reset_invocation_id,
7072
_reset_namespace_prefix,
7173
_set_active_dispatch,
7274
_set_active_observer_span,
7375
_set_active_observers,
76+
_set_branch_name_chain,
7477
_set_correlation_id,
7578
_set_fan_out_index,
79+
_set_fan_out_index_chain,
7680
_set_invocation_id,
7781
_set_namespace_prefix,
7882
current_active_observer_span,
@@ -1438,6 +1442,11 @@ async def innermost(s: Any) -> Mapping[str, Any]:
14381442
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
14391443
namespace_token = _set_namespace_prefix(namespace)
14401444
fan_out_token = _set_fan_out_index(context.fan_out_index)
1445+
# Per proposal 0045 (v0.37.0): drive the per-depth chain
1446+
# ContextVars from the context so ``set_invocation_metadata``
1447+
# sees the full lineage chain at augmentation time.
1448+
fan_out_chain_token = _set_fan_out_index_chain(context.fan_out_index_chain)
1449+
branch_chain_token = _set_branch_name_chain(context.branch_name_chain)
14411450
try:
14421451
try:
14431452
final_partial = await chain(state)
@@ -1459,6 +1468,8 @@ async def innermost(s: Any) -> Mapping[str, Any]:
14591468
# the chain unrecovered. Wrap as NodeException per §4.
14601469
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
14611470
finally:
1471+
_reset_branch_name_chain(branch_chain_token)
1472+
_reset_fan_out_index_chain(fan_out_chain_token)
14621473
_reset_fan_out_index(fan_out_token)
14631474
_reset_namespace_prefix(namespace_token)
14641475
_reset_active_dispatch(dispatch_token)
@@ -1579,6 +1590,9 @@ async def innermost(s: Any) -> Mapping[str, Any]:
15791590
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
15801591
namespace_token = _set_namespace_prefix(namespace)
15811592
fan_out_token = _set_fan_out_index(context.fan_out_index)
1593+
# Per proposal 0045: drive per-depth chain ContextVars.
1594+
fan_out_chain_token = _set_fan_out_index_chain(context.fan_out_index_chain)
1595+
branch_chain_token = _set_branch_name_chain(context.branch_name_chain)
15821596

15831597
try:
15841598
try:
@@ -1593,6 +1607,8 @@ async def innermost(s: Any) -> Mapping[str, Any]:
15931607
# preserved.
15941608
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
15951609
finally:
1610+
_reset_branch_name_chain(branch_chain_token)
1611+
_reset_fan_out_index_chain(fan_out_chain_token)
15961612
_reset_fan_out_index(fan_out_token)
15971613
_reset_namespace_prefix(namespace_token)
15981614
_reset_active_dispatch(dispatch_token)
@@ -1868,6 +1884,9 @@ async def innermost(s: Any) -> Mapping[str, Any]:
18681884
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
18691885
namespace_token = _set_namespace_prefix(namespace)
18701886
fan_out_token = _set_fan_out_index(context.fan_out_index)
1887+
# Per proposal 0045: drive per-depth chain ContextVars.
1888+
fan_out_chain_token = _set_fan_out_index_chain(context.fan_out_index_chain)
1889+
branch_chain_token = _set_branch_name_chain(context.branch_name_chain)
18711890
# Per spec §10.11 the ``fan_out_progress`` entry is "in-flight
18721891
# only"; the fan-out's own completion save below is the last
18731892
# point where the entry is needed (proposal 0009: that save
@@ -1895,6 +1914,8 @@ async def innermost(s: Any) -> Mapping[str, Any]:
18951914
except Exception as e:
18961915
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
18971916
finally:
1917+
_reset_branch_name_chain(branch_chain_token)
1918+
_reset_fan_out_index_chain(fan_out_chain_token)
18981919
_reset_fan_out_index(fan_out_token)
18991920
_reset_namespace_prefix(namespace_token)
19001921
_reset_active_dispatch(dispatch_token)
@@ -2084,6 +2105,9 @@ async def innermost(s: Any) -> Mapping[str, Any]:
20842105
dispatch_token = _set_active_dispatch(lambda event: _dispatch(context, event))
20852106
namespace_token = _set_namespace_prefix(namespace)
20862107
fan_out_token = _set_fan_out_index(context.fan_out_index)
2108+
# Per proposal 0045: drive per-depth chain ContextVars.
2109+
fan_out_chain_token = _set_fan_out_index_chain(context.fan_out_index_chain)
2110+
branch_chain_token = _set_branch_name_chain(context.branch_name_chain)
20872111
try:
20882112
try:
20892113
final_partial = await chain(state)
@@ -2092,6 +2116,8 @@ async def innermost(s: Any) -> Mapping[str, Any]:
20922116
except Exception as e:
20932117
raise NodeException(node_name=current, cause=e, recoverable_state=state) from e
20942118
finally:
2119+
_reset_branch_name_chain(branch_chain_token)
2120+
_reset_fan_out_index_chain(fan_out_chain_token)
20952121
_reset_fan_out_index(fan_out_token)
20962122
_reset_namespace_prefix(namespace_token)
20972123
_reset_active_dispatch(dispatch_token)
@@ -2173,6 +2199,11 @@ def _dispatch_started(
21732199
fan_out_config=fan_out_config,
21742200
parallel_branches_config=parallel_branches_config,
21752201
branch_name=current_branch_name(),
2202+
# Per proposal 0045: per-depth lineage chains so
2203+
# observers can identify the augmenter's call-stack
2204+
# ancestor path under nested dispatch.
2205+
fan_out_index_chain=context.fan_out_index_chain,
2206+
branch_name_chain=context.branch_name_chain,
21762207
subgraph_identities=context.subgraph_identities,
21772208
caller_invocation_metadata=current_invocation_metadata(),
21782209
),
@@ -2210,6 +2241,9 @@ def _dispatch_completed(
22102241
fan_out_config=fan_out_config,
22112242
parallel_branches_config=parallel_branches_config,
22122243
branch_name=current_branch_name(),
2244+
# Per proposal 0045: per-depth lineage chains.
2245+
fan_out_index_chain=context.fan_out_index_chain,
2246+
branch_name_chain=context.branch_name_chain,
22132247
subgraph_identities=context.subgraph_identities,
22142248
caller_invocation_metadata=current_invocation_metadata(),
22152249
),

src/openarmature/graph/events.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,17 @@ class NodeEvent:
257257
# simultaneously when a branch's subgraph contains a fan-out
258258
# (and vice versa).
259259
branch_name: str | None = None
260+
# Per proposal 0045 (v0.37.0): per-depth lineage chains parallel
261+
# to ``namespace``. Position ``i`` is the fan_out_index (or
262+
# branch_name) at the dispatch boundary leading to namespace
263+
# depth ``i+1`` — or ``None`` when that boundary is a subgraph
264+
# wrapper (not a fan-out, not a parallel-branches branch).
265+
# ``fan_out_index`` and ``branch_name`` above carry the
266+
# INNERMOST values; the chains carry the full lineage so
267+
# observers can apply the §3.4 lineage-aware boundary rule
268+
# without re-deriving it from successive events.
269+
fan_out_index_chain: tuple[int | None, ...] = ()
270+
branch_name_chain: tuple[str | None, ...] = ()
260271
# Per observability §5.3 + the coord-thread
261272
# ``clarify-subgraph-name-semantics`` resolution: chain of
262273
# compiled-subgraph identities parallel to the wrapper-depth
@@ -328,6 +339,16 @@ class MetadataAugmentationEvent:
328339
attempt_index: int = 0
329340
fan_out_index: int | None = None
330341
branch_name: str | None = None
342+
# Per proposal 0045 (v0.37.0): the augmenter's per-depth lineage
343+
# chain. Two parallel tuples indexed by namespace position —
344+
# position ``i`` is the fan_out_index (or branch_name) at
345+
# namespace depth ``i+1``, or ``None`` if that depth's dispatch
346+
# boundary is not a fan-out instance (not a parallel-branches
347+
# branch). Required by §3.4's lineage-aware boundary rule so
348+
# observers can identify the augmenter's call-stack ancestor
349+
# chain rather than only the innermost dispatch.
350+
fan_out_index_chain: tuple[int | None, ...] = ()
351+
branch_name_chain: tuple[str | None, ...] = ()
331352

332353

333354
# Spec: realizes observability §8.4.1 *Trace input/output sourcing*

src/openarmature/graph/observer.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ class _InvocationContext:
416416
# instance, and absent (None) for nodes outside any fan-out.
417417
fan_out_index: int | None = None
418418

419+
# Per proposal 0045 (v0.37.0): per-depth lineage chains. Mirror
420+
# ``namespace_prefix`` depth — position ``i`` is the
421+
# fan_out_index (resp. branch_name) for the dispatch boundary
422+
# at namespace depth ``i+1``, or ``None`` if that boundary is a
423+
# subgraph wrapper / serial node (not a fan-out, not a
424+
# parallel-branches branch). The chains are extended by one
425+
# entry at every ``descend_into_*`` call; the engine then drives
426+
# the chain ContextVars from these fields at every node-execution
427+
# site so ``set_invocation_metadata`` sees the full chain.
428+
fan_out_index_chain: tuple[int | None, ...] = ()
429+
branch_name_chain: tuple[str | None, ...] = ()
430+
419431
# ----------------------------------------------------------------
420432
# Checkpointing fields (spec pipeline-utilities §10)
421433
#
@@ -551,6 +563,11 @@ def descend_into_subgraph(
551563
parent_states_prefix=self.parent_states_prefix + (parent_state,),
552564
subgraph_identities=self.subgraph_identities + (subgraph_identity,),
553565
fan_out_index=self.fan_out_index,
566+
# Per proposal 0045: subgraph wrappers don't add a
567+
# fan-out or branch axis — extend both chains by
568+
# ``None`` at this depth.
569+
fan_out_index_chain=self.fan_out_index_chain + (None,),
570+
branch_name_chain=self.branch_name_chain + (None,),
554571
invocation_id=self.invocation_id,
555572
correlation_id=self.correlation_id,
556573
checkpointer=self.checkpointer,
@@ -602,6 +619,11 @@ def descend_into_fan_out_instance(
602619
parent_states_prefix=self.parent_states_prefix + (parent_state,),
603620
subgraph_identities=self.subgraph_identities + (subgraph_identity,),
604621
fan_out_index=fan_out_index,
622+
# Per proposal 0045: fan-out instance descent extends the
623+
# fan_out_index chain with the instance's index; the
624+
# branch chain extends with ``None`` (no branch axis here).
625+
fan_out_index_chain=self.fan_out_index_chain + (fan_out_index,),
626+
branch_name_chain=self.branch_name_chain + (None,),
605627
invocation_id=self.invocation_id,
606628
correlation_id=self.correlation_id,
607629
checkpointer=self.checkpointer,
@@ -626,6 +648,8 @@ def descend_into_parallel_branch(
626648
parallel_branches_node_name: str,
627649
parent_state: State,
628650
sub_attached: tuple[SubscribedObserver, ...],
651+
*,
652+
branch_name: str,
629653
) -> _InvocationContext:
630654
"""Build the context for one parallel-branches branch's
631655
subgraph invocation.
@@ -637,11 +661,13 @@ def descend_into_parallel_branch(
637661
inner events nest under it (mirrors
638662
``descend_into_fan_out_instance``'s namespace stamping).
639663
640-
Branch identity lives on the
641-
``observability.correlation._branch_name_var`` ContextVar
642-
rather than on the descend context; set inside the
643-
branch's task closure so ``copy_context`` inherits it
644-
through the subgraph's execution.
664+
Branch identity (the SCALAR innermost branch_name) lives on
665+
the ``observability.correlation._branch_name_var`` ContextVar
666+
— set inside the branch's task closure so ``copy_context``
667+
inherits it through the subgraph's execution. The PER-DEPTH
668+
``branch_name_chain`` (proposal 0045) is extended here on the
669+
context so the engine can drive the chain ContextVar at
670+
every inner-node execution site.
645671
646672
Per §11.9 / §10.7 atomic-restart: drops the checkpointer
647673
and pending_resume_states (a crash mid-dispatch re-runs the
@@ -662,6 +688,12 @@ def descend_into_parallel_branch(
662688
# needed) is a future addition.
663689
subgraph_identities=self.subgraph_identities + (None,),
664690
fan_out_index=self.fan_out_index,
691+
# Per proposal 0045: parallel-branches branch descent
692+
# extends the branch chain with this branch's name; the
693+
# fan_out_index chain extends with ``None`` (no fan-out
694+
# axis here).
695+
fan_out_index_chain=self.fan_out_index_chain + (None,),
696+
branch_name_chain=self.branch_name_chain + (branch_name,),
665697
invocation_id=self.invocation_id,
666698
correlation_id=self.correlation_id,
667699
checkpointer=None,

src/openarmature/graph/parallel_branches.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ async def run_branch(branch_name: str, spec: BranchSpec[Any]) -> Mapping[str, An
161161
parallel_branches_node_name=self.name,
162162
parent_state=state,
163163
sub_attached=tuple(spec.subgraph._attached_observers), # noqa: SLF001
164+
branch_name=branch_name,
164165
)
165166

166167
async def innermost(s: Any) -> Mapping[str, Any]:

0 commit comments

Comments
 (0)