@@ -318,8 +318,10 @@ async def _run_fixture_003(spec: Mapping[str, Any]) -> None:
318318 with the canonical category in the description, an exception
319319 event recorded, and the ``openarmature.error.category``
320320 attribute. Sibling spans before the failure stay OK; the
321- invocation span propagates ERROR via OTel's parent-status-from-
322- failed-children semantics."""
321+ invocation span ends ERROR (OTel doesn't auto-propagate child
322+ status to parents, so the OTelObserver explicitly sets ERROR
323+ on the invocation span when any child errors per
324+ ``_handle_completed``)."""
323325 from opentelemetry .trace import StatusCode
324326
325327 from openarmature .graph import RuntimeGraphError
@@ -361,6 +363,16 @@ async def _run_fixture_003(spec: Mapping[str, Any]) -> None:
361363 f"fail_node MUST have at least one 'exception' event recorded; got { event_names } "
362364 )
363365
366+ # Invocation span ends ERROR when any child errors per spec
367+ # §4.2 / fixture 003. The OTelObserver sets ERROR explicitly in
368+ # ``_handle_completed`` (OTel doesn't auto-propagate child status
369+ # to parents).
370+ inv = by_name .get ("openarmature.invocation" )
371+ assert inv is not None
372+ assert inv .status .status_code == StatusCode .ERROR , (
373+ f"invocation span status MUST be ERROR when a child errored; got { inv .status .status_code } "
374+ )
375+
364376
365377# ---------------------------------------------------------------------------
366378# Fixture 007 — retry attempt spans
@@ -523,10 +535,15 @@ def _classifier(exc: Exception, _state: Any, _transient: frozenset[str] = transi
523535
524536
525537async def _run_fixture_011 (spec : Mapping [str , Any ]) -> None :
526- """Spec §8: deterministic span content (hierarchy, span names,
527- span status, attributes minus the non-deterministic-by-design
528- ignore list) is identical across two invocations of the same
529- graph with the same input."""
538+ """Spec §8: deterministic span content is identical across two
539+ invocations of the same graph with the same input. The
540+ signature compared per-span:
541+ ``(name, status_code, parent_name, attrs ∖ ignored_set)``.
542+ Parent linkage is encoded as the parent span's NAME rather
543+ than its span_id (span_ids are non-deterministic per OTel SDK's
544+ default RandomIdGenerator); a hierarchy regression where a
545+ node reparented to a different ancestor surfaces as a
546+ parent_name divergence."""
530547 cases = cast ("list[dict[str, Any]]" , spec ["cases" ])
531548 for case in cases :
532549 case_name = cast ("str" , case ["name" ])
@@ -539,14 +556,16 @@ async def _run_fixture_011(spec: Mapping[str, Any]) -> None:
539556async def _run_fixture_011_case (case : Mapping [str , Any ]) -> None :
540557 # Translate the fixture's ``when:`` conditional-edge syntax
541558 # (``when: {field: counter, gt: 0}``) into the adapter's
542- # ``condition: {if_field, equals, then, else}`` shape via a
543- # closure-based equality predicate. This case's ``gt: 0``
544- # against an int field is equivalent to ``equals: True`` once
545- # we project the gt-comparison into a state-derived bool — the
559+ # ``condition: {if_field, equals, then, else}`` shape. The
546560 # adapter doesn't have a ``gt`` builder, but the deterministic
547- # input here means the same branch always fires, so the
548- # determinism comparison doesn't depend on which adapter
549- # construct represents the edge.
561+ # input means ``counter == 1`` always — so ``gt: 0`` is
562+ # functionally equivalent to ``equals: 1`` for this fixture's
563+ # flow. The determinism comparison itself doesn't depend on
564+ # which adapter construct represents the edge; the same
565+ # branch always fires under identical inputs. (Generic
566+ # ``gt``/``lt``/etc. edge-condition support is tracked under
567+ # the Harness backlog in
568+ # ``openarmature-coord/docs/phase-6-1-conformance-fillin.md``.)
550569 case_for_build = _translate_011_when_edges (case )
551570
552571 invocations = int (case .get ("invocations" , 2 ))
@@ -569,23 +588,43 @@ async def _run_fixture_011_case(case: Mapping[str, Any]) -> None:
569588 f"deterministic input MUST produce equal span counts; got { len (runs [0 ])} vs { len (runs [1 ])} "
570589 )
571590
572- # Compare structurally. Group each run's spans by name, then
573- # within each name-bucket compare deterministic attributes +
574- # status + parent-by-name (parent span_ids are
575- # non-deterministic, so we walk by name).
576- def _signature (span : Any ) -> tuple [str , str , tuple [tuple [str , Any ], ...]]:
591+ # Compare each span's structural signature across runs. Span
592+ # span_ids are non-deterministic, so we encode the parent
593+ # linkage by looking up parent.span_id in the same run's
594+ # by-id map and including the parent's NAME in the signature.
595+ # That way a hierarchy regression (e.g., a node reparented
596+ # from invocation to a sibling) shows up as a signature
597+ # difference even though both spans' own attributes are
598+ # unchanged.
599+ def _signature (
600+ span : Any , by_id : Mapping [int , Any ]
601+ ) -> tuple [str , str , str | None , tuple [tuple [str , Any ], ...]]:
577602 attrs = dict (span .attributes or {})
578603 deterministic_items = sorted (
579604 (k , _normalize_attr_value (v )) for k , v in attrs .items () if k not in _DETERMINISM_IGNORED_ATTRS
580605 )
606+ parent_name : str | None = None
607+ if span .parent is not None :
608+ parent_span = by_id .get (span .parent .span_id )
609+ if parent_span is not None :
610+ parent_name = cast ("str" , parent_span .name )
581611 return (
582612 cast ("str" , span .name ),
583613 str (span .status .status_code ),
614+ parent_name ,
584615 tuple (deterministic_items ),
585616 )
586617
587- sig_run_0 = sorted (_signature (s ) for s in runs [0 ])
588- sig_run_1 = sorted (_signature (s ) for s in runs [1 ])
618+ by_id_run_0 : dict [int , Any ] = {}
619+ for s in runs [0 ]:
620+ if s .context is not None :
621+ by_id_run_0 [s .context .span_id ] = s
622+ by_id_run_1 : dict [int , Any ] = {}
623+ for s in runs [1 ]:
624+ if s .context is not None :
625+ by_id_run_1 [s .context .span_id ] = s
626+ sig_run_0 = sorted (_signature (s , by_id_run_0 ) for s in runs [0 ])
627+ sig_run_1 = sorted (_signature (s , by_id_run_1 ) for s in runs [1 ])
589628 assert sig_run_0 == sig_run_1 , (
590629 f"deterministic span content MUST match across runs; "
591630 f"first divergence: run_0={ sig_run_0 !r} vs run_1={ sig_run_1 !r} "
0 commit comments