Documented divergences and additions relative to the canonical attractor nlspec at
github.com/strongdm/attractor. The current
canonical snapshot lives at specs/canonical/attractor-spec-canonical.md.
All extensions are backward-compatible with the canonical spec — community .dot files
written against the canonical spec should continue to work without modification.
When in doubt about whether a behavior is spec-conformant, consult the canonical snapshot before assuming it is a bug.
Every entry below carries (or, for older entries, has been backfilled with) two mandatory fields declared in the entry's banner blockquote (or immediately under the heading, for an entry with no banner):
-
depends-on: §NN(ordepends-on: none) — the section number of any other entry in this file that the current one builds on. This file is a flat chronological list with no built-in dependency tracking;depends-onmakes a stacked extension traceable to its base so a reader (or a future author) can see at a glance that entry N assumes entry M's behavior. An extension that depends on another entry which is itself an undecided or deferred divergence must say so here — don't let a later extension quietly stack on top of an open question for months without anyone noticing. -
upstream action:— required whenever the entry's banner states the behavior DIVERGES from canonical spec (not required for pure additions to spec-silent areas). The value must be one of:- a real link to the upstream PR or issue proposing the change at
strongdm/attractor(e.g.https://github.com/strongdm/attractor/pull/NNor.../issues/NN), or deferred, reason: <one-line reason>, review-by: <YYYY-MM-DD>— a concrete calendar date the deferral will be revisited, not a placeholder, ordeclining, reason: <one-line reason>— an honest statement that no upstream filing is planned, because the evidence says one would not land (e.g. the upstream repo is dormant, has issues disabled, or its own open community PRs of this kind sit unmerged). Declining does not mean the divergence goes unrecorded: it stays exactly here, in this ledger, which is what actually informs consumers of this engine. Unlikedeferred,decliningcarries noreview-bydate because there is no pending action to revisit on a calendar — only new upstream conditions (the repo becoming active again, issues reopening, a maintained fork appearing) would warrant reopening the entry.
A non-date value for
review-by("eventually", "TBD", "soon", "when we get to it") is not a permitted value. Prose promising an upstream proposal with no date and no link is how a divergence sits unreviewed for months; a date makes it someone's job on a specific day. When areview-bydate passes without the proposal being filed, the entry must be revisited: either file it, replace the date with a fresh one and a fresh reason, or — if the honest conclusion is that filing was never going to land — switch todeclining. - a real link to the upstream PR or issue proposing the change at
What: The value grammar accepts unquoted bare identifiers in addition to quoted strings.
Examples: shape=box, rankdir=LR, node_type=llm. The grammar production is:
BareValue ::= [A-Za-z_][A-Za-z0-9_.:-]*
Why: Graphviz DOT source uses bare identifiers pervasively for built-in shape and
direction attributes. Requiring quotes everywhere would break existing community .dot
files. This is an additive clarification of what Graphviz already accepts; it is not a
departure from spec intent.
Compatibility: Fully backward-compatible. Quoted values continue to work unchanged.
What: The graph-level retry ceiling attribute is default_max_retries (plural). The
singular default_max_retry is accepted as a legacy alias and maps to the same behavior.
Default value is 0 (no retries unless explicitly configured).
Why: The plural form is grammatically clearer ("the number of retries" rather than "the
maximum retry"). The legacy alias ensures any existing .dot files using the original
singular name continue to work without modification.
Compatibility: Both names are valid. Prefer default_max_retries in new pipelines.
What: When a node omits the max_retries attribute, it inherits the graph's
default_max_retries value rather than defaulting to 0 independently. This allows a
single graph-level setting to establish a retry policy for all nodes simultaneously.
Why: Without inheritance, authors must repeat max_retries=N on every node that
should participate in a retry policy. The inheritance behavior is the natural complement to
default_max_retries existing at all: a graph-level default that nothing inherits would
serve no purpose.
Compatibility: Only observable in pipelines that set default_max_retries at the
graph level. Pipelines that do not set it see no change (effective retries remain 0).
What: A node marked goal_gate=true is considered satisfied by either SUCCESS or
PARTIAL_SUCCESS outcome status. It is NOT satisfied by FAIL, SKIP, or other
statuses, and the pipeline exits with an unsatisfied-goal error if the node does not
reach at least PARTIAL_SUCCESS.
Why: Rigid SUCCESS-only gate semantics are too coarse for pipelines that implement
best-effort or iterative workflows — for example, a test-generation node that passes most
cases but flags a few as needing human review. Accepting PARTIAL_SUCCESS preserves the
gate intent (the node ran and made meaningful progress) while not blocking pipelines that
legitimately reach a partial outcome.
Compatibility: Existing goal_gate=true nodes that return SUCCESS are unaffected.
Nodes that return PARTIAL_SUCCESS now satisfy the gate where they previously would have
caused a pipeline failure.
What: The execution lifecycle includes six phases rather than five:
PARSE -> TRANSFORM -> VALIDATE -> INITIALIZE -> EXECUTE -> FINALIZE
The TRANSFORM phase applies parse-time transforms (stylesheet resolution, variable expansion, and custom AST transforms) before validation runs.
Why: Placing transforms before validation ensures that validation sees the final, expanded graph — not the template form with unexpanded variables or unresolved stylesheets. This prevents spurious validation failures on legal pipeline patterns that are valid only after expansion.
Compatibility: Pipeline authors who consume the execution lifecycle events or hook into
the lifecycle will see a new TRANSFORM phase event before VALIDATE. Pipelines that do
not hook into lifecycle events are unaffected.
What: Handler error paths use RETURN Outcome(status=FAIL, ...) rather than raising
exceptions. Unhandled exceptions in handler code are caught and wrapped into a FAIL
outcome with the exception message in notes.
Why: Exception propagation from a handler would terminate the entire pipeline rather
than routing through the graph's conditional edges. Returning a FAIL outcome preserves
the pipeline's ability to dispatch to a failure branch (e.g., a condition="outcome=fail"
edge to a recovery node or human gate). This is the behavior authors expect: a failed node
should trigger failure-path routing, not crash the pipeline.
Compatibility: This is an implementation detail of the engine. Pipeline authors observe
FAIL outcomes on handler errors regardless of whether the internal mechanism uses
exceptions or return values. Existing pipelines are unaffected.
What: The externally visible DOT attribute name for the node handler type is type.
The engine may use an internal field named node_type to avoid reserved-word conflicts in
Python (where type is a built-in). Both names refer to the same concept; the external
behavior is identical.
Why: Python's type built-in creates naming conflicts in dataclasses and attribute
access. Using node_type internally avoids shadowing the built-in. The DOT attribute name
type remains canonical and externally visible.
Compatibility: Pipeline authors use type=llm, type=parallel, etc. in DOT source.
The internal renaming is invisible at the DOT level.
What: Our implementation realizes the spec's §5.4 full-fidelity "reused session / same
thread" behavior via an internal _session_pool on the backend \u2014 an implementation construct
below the spec's CodergenBackend run(node, prompt, context) interface (the spec models no
session object). As of this change, when a node executes inside a parallel branch, its
session pool and completion-tracking state are isolated per branch: each branch runs on a
branch-scoped engine with a cloned backend. Concurrent branches no longer share session state.
Why: §3.8 mandates that "each parallel branch receives an isolated clone of the context."
Our _session_pool sits below the spec's abstraction, so the spec does not explicitly govern
it \u2014 but sharing it across concurrent branches violated the spec's isolation intent and our
own §4.12 handler-statelessness rule, producing silent non-deterministic cross-branch
contamination under fidelity=full. Per-branch isolation extends the spec's isolation intent
down to our session-pool layer.
Compatibility: Fully backward-compatible. Sequential pipelines and parallel pipelines
without nested stateful codegen see no change. No spec-conformant .dot file can depend on
cross-branch session sharing, because the spec never defines that behavior \u2014 it defines the
opposite (§3.8 isolation). This change moves observable behavior toward what a conforming
pipeline already assumes.
Implementation note:
_session_poolwas superseded by_thread_transcripts(see §12–13); the per-branch isolation semantics described here remain in effect.
What: The spec contains an unresolved interaction: §5.4 thread-resolution says nodes
sharing a thread_id "reuse the same LLM session," while §3.8 says parallel branches must be
isolated. When the same explicit thread_id appears on nodes in two different concurrent
parallel branches, these two rules conflict. Our implementation resolves this by giving
§3.8 (branch isolation) precedence: each branch's nodes get an isolated session even if
they carry an identical thread_id to a sibling branch's nodes. Thread-id-based session reuse
continues to work normally for the sequential case (nodes in the same linear path).
Why: §3.8's isolation mandate is the stronger, more consistent guarantee; a shared LLM session across concurrent branches is precisely the contamination this change eliminates. "Isolate by default" is the safe, deterministic resolution of a spec self-contradiction.
Compatibility: Backward-compatible for all spec-conformant pipelines except the narrow,
spec-self-contradictory case of an author deliberately placing the same thread_id on nodes
in different concurrent branches expecting them to share one session \u2014 a behavior the spec
never coherently defines. Such a pipeline relies on undefined/contradictory behavior; we make
the resolution explicit and deterministic here.
What: We support a sub-pipeline node declared via shape=folder with a dot_file=
attribute, which runs an entire child .dot graph as a single node's execution. The spec
describes sub-pipeline composition as a pattern (§9.4 \u2014 "a node whose handler runs an entire
sub-graph as its execution," with the manager loop named as the example) but does not define a
dedicated shape=folder shape or dot_file= attribute for it.
Why: A first-class folder/sub-pipeline node is ergonomic for composing pipelines from
reusable .dot fragments without the manager-loop supervisor machinery. It implements the
spec's §9.4 sub-pipeline pattern with a dedicated, declarative shape.
Compatibility: Additive and non-shadowing. folder is not a spec-assigned shape in the
§2.8 shape\u2192handler table, and dot_file does not collide with any spec-defined attribute
name, so the mechanism cannot change the behavior of any spec-conformant .dot file.
(Documenting a pre-existing extension that was previously undocumented.)
dot_file= path resolution: A relative dot_file= value is resolved by
resolve_dot_path() (handlers/pipeline.py) against a precedence chain, not a search
path -- the first non-empty candidate wins, with no existence check:
- Absolute path -- used as-is.
graph.source_dir-- the directory of the.dotfile that produced the current graph (root or child).context.target_dir-- the pipeline's working directory (--cwdon the standalone CLI; the mounted orchestrator has no equivalent and skips this tier).os.getcwd()-- the process's current working directory, as a last resort.
Every child graph reached through a shape=folder node already gets its source_dir
set to its own .dot file's directory (PipelineHandler.execute(), step 5), so a
grandchild's relative dot_file= resolves beside the child regardless of where the root
came from. A root graph gets its source_dir seeded from the directory of the .dot
file passed to the entry point that invoked it -- the standalone CLI (attractor run <file>), the mounted PipelineOrchestrator (a local dot_file in its config), and the
run_pipeline tool (a dot_file input, forwarded to the mounted orchestrator's spawned
child session as an explicit source_dir alongside the already-resolved DOT text) all seed
it this way. Only an inline DOT source (--dot-source, a dot_source config value, or
a dot_source tool input) has no backing file and therefore no directory to seed --
source_dir stays empty for that root.
context.target_dir (--cwd) is an independent knob and does not shadow source_dir.
It answers a different question -- where box/tool nodes write files and read relative
inputs at runtime -- while source_dir answers where the pipeline's own .dot tree lives
on disk. The precedence chain above means an explicitly-set graph.source_dir always wins
over context.target_dir for dot_file= resolution: pointing --cwd at a separate
workspace does not require flattening a multi-file pipeline into that workspace.
What: Same-thread_id LLM session continuity (§5.4 thread resolution) applies WITHIN a
single graph traversal. It does NOT cross a sub-pipeline boundary: a node inside a
shape=folder / dot_file= sub-pipeline (§9.4) or a manager-loop child dotfile (§4.11) runs
as a separate child graph/engine and starts a fresh LLM session, even if it carries the same
thread_id as a node in the parent graph. Session continuity for a shared thread_id holds
for inline nodes and flattened DOT subgraph cluster_* blocks (which §11.1 flattens into the
same graph), but not across a child-graph execution boundary.
Why: The spec frames sessions as run-local and non-serializable (§5.3: "in-memory LLM sessions cannot be serialized"; §3.1 finalize closes sessions), the thread-resolution ladder is graph-scoped (§5.4, tier 3 is "graph-level default thread"), and §9.4 defines a sub-pipeline as "a node whose handler runs an entire sub-graph as its execution" — a separate execution unit. This matches the subagent model (coding-agent-loop §7.1: a child session "runs its own agentic loop with its own conversation history but shares the parent's execution environment"). Our implementation makes this concrete: a sub-pipeline / manager child runs on a child engine with its own session pool. The spec does not explicitly state cross-sub-pipeline continuity either way; we adopt "fresh boundary" as the deterministic, spec-intent-aligned choice, consistent with the per-branch isolation decisions in sections 8 and 9.
Compatibility: Backward-compatible. No spec-conformant .dot can depend on
cross-sub-pipeline session continuity, because the spec never promises it and the surrounding
normative clauses (§5.3, §5.4, §9.4) indicate the opposite. Authors who need a node to continue
a shared-thread_id session must keep it inline in the same graph (or in a flattened cluster),
not behind a sub-pipeline / folder / manager-child boundary.
What: The spec's §5.4 full-fidelity "reuse the same session / full history preserved"
requirement is realized in our implementation by a backend-held message-list carrier injected
into each subsequent same-thread spawn via the parent_messages mechanism (foundation
_prepared.py §4.5 leave-open). The carrier holds node-exchange granularity: one
(role=user, content=instruction) + (role=assistant, content=final_output) pair per full
node. The child agent's inner tool-loop turns are not included — only the conversation
between nodes is preserved, not the child's internal reasoning.
Why: The spec's §5.4 language ("reuse the same LLM session", "full history preserved") is
written as a behavior specification, not a mechanism mandate. The spec separately notes
(§5.3) that sessions are in-memory and non-serializable, and unified-llm §2.6 models the LLM
client as stateless (continuity = caller-passed message list). Our realization of §5.4 using
parent_messages is mechanism-not-policy: the spec's §4.5 CodergenBackend interface is
silent on how continuity is achieved, leaving this to the app layer. Node-exchange granularity
(instruction + final output) was accepted as the meaning of full at the backend layer — the
spawn result exposes only output + session_id, not inner tool-loop turns, so inner-turn
fidelity across nodes is architecturally inaccessible at this layer.
Compatibility: Additive and non-breaking. Prior behavior (sub_session_id re-pass) was
silently broken — it never preserved history because session_id is an identity/trace token,
not a history pointer. This change restores the spec-mandated behavior. No spec-conformant
.dot file can depend on the broken non-continuity.
What: fidelity=full session continuity (§5.4 thread resolution) is branch-local: the
backend's _thread_transcripts carrier is reset to {} when a backend is cloned for a
parallel branch (clone()). Two sibling branches that both carry an explicit thread_id
do not share conversation history — each branch accumulates its own independent
transcript. Thread-id-based history continuity operates only within a single linear path
(i.e., a single branch's sequential execution).
Why: This resolves the same §5.4 vs §3.8 spec conflict addressed in §9 (per-branch session-pool isolation): §3.8 isolation (each parallel branch receives an independent clone) takes precedence over §5.4 thread-id-based reuse when the two rules conflict. Isolation is the deterministic, safe resolution — a shared conversation across concurrent branches is precisely the cross-contamination the per-branch isolation design eliminates. The transcript isolation is a natural consequence of the backend clone resetting mutable state.
Compatibility: Backward-compatible. The prior implementation was broken for cross-node continuity regardless of branching, so no existing pipeline could have been relying on cross-branch conversation sharing. Authors who intend a shared thread to carry history across nodes must place those nodes in the same sequential path (not in sibling parallel branches).
What: The canonical spec scopes allow_partial (§2.6) to a single trigger: "Accept
PARTIAL_SUCCESS when retries are exhausted instead of failing" (§5.2 retry pseudocode). We
extend it to a second trigger: when a node with allow_partial set exceeds its timeout
(§2.6), the engine yields PARTIAL_SUCCESS instead of FAIL. Because PARTIAL_SUCCESS is
success-class for routing (§5.2), the graph continues along the timed-out node's unconditional
edge rather than terminating the run. Without allow_partial, a timeout still produces FAIL
and flows through normal failure routing (§3.7) — unchanged.
Why: For iterative loops (a node meant to make incremental progress across many
executions, with progress recorded in context/files), a single slow iteration hitting its
timeout would otherwise tear down the entire run via §3.7 termination. allow_partial is the
author's explicit opt-in that an incomplete-but-progressing node is "good enough to proceed" —
the same intent the spec already honors for retry exhaustion and that §4 honors for goal gates.
Applying it on the timeout path extends that intent to the one other place a node can fail to
fully complete. The behavior is gated entirely behind the opt-in attribute; nodes without it
see no change.
Note on attribute spelling: This extension also corrects a string-vs-bool defect at the
allow_partial call sites. The DOT parser coerces unquoted allow_partial=true to bool
True but leaves quoted allow_partial="true" as the string "true"; the call sites
previously tested attrs.get("allow_partial") is True, which never matched the quoted form —
so allow_partial was inert for the common quoted spelling on both the retry-exhaustion and
timeout paths. Both call sites now accept bool True or the string "true", so both DOT
spellings behave identically (consistent with extension §1, BareValue, where quoted and
unquoted values are equivalent).
Compatibility: Fully backward-compatible. Nodes without allow_partial are unaffected
(timeout still routes via §3.7). Nodes that set it now continue past a timeout where they
previously terminated the run — moving observable behavior toward the author's stated intent.
No spec-conformant .dot file can depend on the prior "single timeout kills the graph despite
allow_partial" behavior, since that was the defect this corrects.
What: A graph-level attribute max_pipeline_duration (integer, milliseconds) that is NOT
defined in the upstream attractor nlspec. When set, the engine checks elapsed wall-clock time
before each step; if the elapsed time exceeds max_pipeline_duration, the pipeline terminates
immediately with status=FAIL and failure_reason="max_pipeline_duration_exceeded".
Why: The upstream spec's step-count ceiling (max_steps) guards against infinite loops but
does not bound wall-clock time. Long-running nodes (network calls, LLM invocations) can stall
a pipeline for an unbounded duration even within the step ceiling. max_pipeline_duration
provides an independent wall-clock safety bound that is orthogonal to step count and useful
for production deployments with SLA requirements.
Behavior:
- Checked before each step in the main execution loop (Step 0 in the engine's step dispatch).
- Measured via
time.monotonic()(elapsed milliseconds since pipeline start). - Terminates the pipeline without executing the current step if the limit is exceeded.
- The termination outcome carries
failure_reason="max_pipeline_duration_exceeded"and a human-readablenotesmessage showing the configured limit.
Implementation locations:
engine.py:280–291— enforcement logic (Step 0 duration check)graph.py:301—max_pipeline_duration: int | Nonefield on theGraphmodel (milliseconds)dot_parser.py:444— promotes the DOT graph-block attribute tograph_fields, coercing toint
Compatibility: Additive. Pipelines that do not set max_pipeline_duration are unaffected
(the attribute defaults to None and the check is skipped). The attribute name does not collide
with any upstream spec-defined graph attribute.
What: On a node FAIL outcome, unconditional out-edges are followed only if the target
node declares runs_on ∈ {always, failure}; otherwise routing stops (fail-fast). The
continue_on_fail attribute opts a node out of fail-fast propagation. Canonical §3.3 step 4
selects the highest-weight unconditional edge regardless of outcome status.
Why: Fail-fast prevents a failed stage from silently feeding garbage into downstream work.
Cleanup/notification nodes can still run via runs_on=always|failure. This is the engine's
"fail loud, don't proceed in a lesser state" stance.
Compatibility: Pipelines with no failures behave identically to canonical. Pipelines that
relied on canonical "continue past FAIL on the best unconditional edge" must add
runs_on=always (or continue_on_fail) to the intended successor.
What: Nodes may declare requires=<keys> and outputs=<keys>. A node whose required
inputs are absent (e.g. produced by a skipped/failed upstream node) is itself skipped, emitting
PIPELINE_NODE_SKIPPED; a node that completes without producing its declared outputs emits
PIPELINE_NODE_CONTRACT_VIOLATION. Skips propagate along the dependency chain.
Why: Makes data dependencies explicit and turns "ran but produced nothing useful" into a loud, observable event rather than a silent downstream failure.
Compatibility: Additive — nodes that declare neither attribute are unaffected.
What: shape=parallel fan-out supports join policies beyond canonical wait_all /
first_success: k_of_n (proceed when k branches succeed), quorum, and a configurable
error_policy governing how branch errors affect the join.
Why: Real fan-out workloads need partial-completion semantics (e.g. "best 3 of 5 drafts") without hand-rolling them in conditions.
Compatibility: Additive — default join behavior matches canonical wait_all.
What: The human-gate node supports a freeform response mode (open text, not only
accelerator-key choices) and file attachments alongside the human's response.
Why: Review gates often need a paragraph of guidance or a file, not just an approve/reject keypress.
Compatibility: Additive — accelerator-key gates behave as in canonical.
What: shape=tool nodes support parse_json (parse stdout as JSON into context),
tool_env (inject env vars for the command), and expose tool.last_line as a routing key in
addition to tool.output.
Why: Tools that emit JSON or a terminal status line are common; routing on last_line
avoids brittle full-stdout matching (the canonical "prose-vs-JSON" hazard).
Compatibility: Additive — tool.output and existing tool routing are unchanged.
What: Prompt/attribute substitution supports $param and ${key} forms in addition to the
canonical $goal, resolving against pipeline context. Substitution remains simple string
replacement, not a templating engine (consistent with canonical §4.5).
Why: Pipelines need to thread context values (not just the goal) into prompts without a full template language.
Compatibility: Additive — $goal behaves as in canonical; literals without $/${ are
untouched.
What: In edge conditions, the outcome key resolves to outcome.preferred_label when set
(via report_outcome), falling back to outcome.status. Canonical §10.4 defines outcome as
outcome.status only, with preferred_label as a separate key.
Why: Lets a node steer its own routing by emitting a preferred_label through
report_outcome, which is load-bearing for outcome-driven pipelines.
Compatibility: Not behavior-neutral. A canonical pipeline matching outcome=<status>
still works unless a node also sets a preferred_label, in which case outcome= matches the
label. Pipelines needing strict status matching should branch on the explicit status value.
Tracked as gap ATX-5 in SPEC_CONFORMANCE.md.
This extension is NOT in the canonical attractor spec. Canonical §4.5 explicitly keeps output format at the backend layer, outside the DOT pipeline language. This attribute is an additive, backward-compatible extension that is safe to ignore by spec-conformant backends that do not support it.
What: A node may carry a response_schema DOT attribute that declares a JSON Schema object
for its LLM response. When set, the pipeline engine passes a ResponseFormat(type="json_schema", json_schema=<schema>, strict=True) to the unified-llm-client's generate() call, requesting
provider-native structured output. The raw JSON text returned by the LLM is stored as the node's
output (outcome.notes) and the parsed object is also stashed in pipeline context under the node
ID for downstream use.
Value forms — either of:
- Inline JSON object (trimmed value starts with
{): the attribute value is parsed directly as a JSON object. Example:extract [response_schema="{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}"] - File path (any other value): resolved relative to the
.dotfile's directory (or the current working directory if the graph was loaded from an inline string). The file must contain a valid JSON object. Example:extract [response_schema="schemas/person.json"]
Fail-loud: If the value is neither valid inline JSON nor a readable file containing a valid
JSON object, apply_transforms() raises ValueError immediately with a clear message — no
silent skip, no proceeding without a resolved schema.
Provider threading: The resolved schema is passed as ResponseFormat(type="json_schema", json_schema=schema, strict=True) to unified_llm.generate(). Provider mapping is handled by
the unified-llm-client library:
- OpenAI: native
response_formatJSON Schema mode. - Gemini:
response_mime_type="application/json"+response_schema. - Anthropic: tool-extraction technique (the library synthesizes a
__structured_output__tool).
Spawn path limitation: response_schema is only supported when the node executes via the
direct-LLM path (AmplifierBackend Path B or DirectProviderBackend). If a node with
response_schema routes through AmplifierBackend._run_with_spawn (Path A — full child
session), the engine returns Outcome(FAIL) with a clear message:
"response_schema is only supported on direct-LLM nodes (not spawned-agent nodes) yet."
Downstream: The structured JSON string is set as outcome.notes. The parsed object (when
JSON is valid) is stored in pipeline context as context[node.id] for downstream nodes to
reference via ${node_id} substitution or direct context lookup.
Compatibility: Fully additive and backward-compatible. Nodes without response_schema are
unaffected. Existing .dot files work without modification. Canonical spec-conformant backends
that do not read response_schema will silently treat it as an unknown attribute (per the
existing unknown-attr passthrough behaviour of dot_parser.py::_apply_node).
This extension is NOT in the canonical attractor spec. The canonical spec defines the run directory layout (Appendix C / Section 5.6) but does not specify per-iteration sub-directories or a
trace.jsonldescent curve. This extension is additive and backward-compatible.
What: Three coordinated additions that make the attractor convergence claim measurable:
-
Per-iteration node records — on each
loop_restartedge traversal the engine createslogs_root/iteration_N/<node_id>/status.jsonalongside the existing flatlogs_root/<node_id>/status.json(backward compat). Each iteration's records are preserved; a 10-iteration run yields 10 complete per-iteration snapshots, none overwritten. -
$iteration/$loop_countcontext keys — the engine seedsiterationandloop_countintoPipelineContextat pipeline start (value"0") and increments both on everyloop_restart. Because the substitution machinery (substitution.py) already expands$keyfrom context,$iterationand$loop_countare immediately usable inpromptattributes andtool_commandstrings without any additional wiring. -
trace.jsonldescent curve — the engine appends one JSONL record tologs_root/trace.jsonlafter every node completion (including skipped nodes). Record shape:{"iteration": 0, "node_id": "work", "status": "success", "preferred_label": "go", "duration_ms": 42.1, "ts": "2024-01-01T00:00:00+00:00"}The file is append-only and engine-written (not hook-derived), so it survives without any hook configured. Reading it across all iterations gives the descent curve: gate signals and durations per node per iteration.
-
attractor trace <run-dir>CLI subcommand — readstrace.jsonland prints a human-readable summary of iterations, nodes, statuses, and durations. Exits 0 even if notrace.jsonlexists (run directories that predate this extension).
Why: The attractor claim is convergence: work descends toward a verified sink. Without
per-iteration records, "converged" and "got lucky once" are indistinguishable. trace.jsonl is
the empirical form of the convergence claim — a descent curve that can be plotted, compared
across runs, and used as evidence in evals. $iteration lets pipeline authors write prompts
that reference the current iteration number (e.g. "This is attempt $iteration — previous
attempts failed because…").
Canonical spec note: The canonical spec should gain matching vocabulary for per-iteration run directories and a trace artifact in the Appendix C run-directory layout section. Until then, this extension documents the behavior here.
Backward compatibility: Fully additive.
- Existing pipelines that do not use
loop_restartsee no change (iteration stays"0"andtrace.jsonlrecords only that single pass). - The flat
logs_root/<node_id>/status.jsonpath is preserved alongside the newiteration_N/<node_id>/status.jsonpath — existing consumers that read the flat path are unaffected. $iterationand$loop_countin context are new keys; existing pipelines that happen to use those names as their own context keys will see them overwritten at pipeline start and on eachloop_restart. Pipeline authors should treatiterationandloop_countas reserved context key names going forward.
Implementation locations:
engine.py: _initialize_context()— seedsiterationandloop_countto"0"at startengine.py: run() Step 6 (loop_restart)— increments and re-seeds both keys on restartengine.py: _write_node_status()— writes iteration-scoped path and appends totrace.jsonlmodules/pipeline-runner/amplifier_module_pipeline_runner/cli.py: cmd_trace()— trace subcommand
This extension DIVERGES from canonical spec §4.5. The canonical spec pseudocode (§4.5,
CodergenHandler) returnsOutcome(status=SUCCESS, notes="Stage completed: …")unconditionally for any non-empty string response. This extension changes that behavior forgoal_gate=truenodes. See walk-upstream note inPRINCIPLES.md.depends-on: none
upstream action: declining, reason:
strongdm/attractorhas had no commits since 2026-03-17, has issues disabled, and its own open community spec-correction PRs (#9, #10) have sat unmerged for 4+ months — filing there would not land. The divergence is tracked here instead.
2026-07-28: a 20-node pipeline ran 2.4 hours via the standalone attractor CLI and exited
status=success with zero work product. The convergence judge node (marked goal_gate=true)
wrote "NOT CONVERGED — 2 of 7 criteria pass. The networking implementation does not work and
the harness was never created." — and was recorded outcome=success because _parse_outcome's
final fallback converted the plain-prose response to SUCCESS. The designed replan loop
(max_retries=4, retry_target=analyze_plan) never fired. This extension closes that class of
false success.
Canonical spec §4.5 CodergenHandler pseudocode:
any string response → write response.md → Outcome(status=SUCCESS, notes="Stage completed: …")
This is unconditional: even a response that literally says "NOT CONVERGED" is recorded as SUCCESS. The spec is fail-open by design (it assumes the node's prose is advisory and routing is the caller's responsibility).
Scope decision: goal_gate=true nodes only. A global default flip to RETRY/FAIL for all
plain-text responses would break nearly every existing pipeline (most box nodes in tutorials
and examples end in prose — see backward-compat inventory below). The fail-closed contract
applies only when the node carries goal_gate=true, which already signals that the node's
outcome is load-bearing for pipeline exit.
The verdict-recovery ladder is preserved. _parse_outcome already tries (in order):
- Fenced JSON (
```json … ```) → strip fence, parse as JSON - Pure JSON (
stripped.startswith("{")) → parse, honorstatusfield - Embedded verdict recovery → find last balanced
{…}in prose, parse if it carriesstatus
The fail-closed rule sits below this ladder. Only when every recovery attempt has failed (the output is genuinely plain prose with no parseable verdict) does the fail-closed rule fire. Judges that emit prose + trailing JSON verdicts keep working via path 3.
Status choice: RETRY, not FAIL. FAIL is fail-fast — it does not traverse plain edges
(EXTENSIONS.md §16; edge_selection.py:79-101). A naive FAIL default would convert observer/
reporter nodes with only plain out-edges into hard stops. RETRY respects max_retries (then
degrades to FAIL) and is the appropriate signal for "try again with an explicit verdict." When
max_retries=0, RETRY degrades immediately to FAIL at the goal-gate check.
is_explicit field on Outcome. A new is_explicit: bool field (default False)
distinguishes an asserted verdict from a defaulted one. is_explicit=True is set by every
producer with an unambiguous verdict mechanism:
report_outcometool call (tool-loop, direct-provider, and spawn paths)- pure JSON / fenced JSON / recovered embedded JSON verdicts (
_parse_outcome) - a tool (parallelogram) node's exit code — 0 is an explicit success, nonzero an explicit
fail (
handlers/tool.py); the exit code IS the verdict - verdict-shaped
response_schemastructured output — a capturedreport_outcomecall or astatusfield with a recognized value (see policy decision below) - deterministic handler verdicts that cannot be LLM-defaulted: human-gate selections and
freeform input (
handlers/human.py), structural no-op SUCCESS (start/exit/conditionalhandlers), fan-in ranking (handlers/fan_in.py), and parallel join-policy aggregation (handlers/parallel.py)
is_explicit=False marks defaulted statuses: the plain-prose fallback, empty-response
defaults, a spawn wrapper's status-only completion, non-verdict structured output
(parseable or not — format is not a verdict), and config/environment failures (timeout,
missing tool_command, handler exception) where no verdict was produced.
Enforcement is two-layer (belt and suspenders).
- Parser layer:
_parse_outcomereturns RETRY (not SUCCESS) for plain-prose goal_gate responses, so retry machinery fires at the node. - Gate layer (centralized):
_check_goal_gates()treats a gate as satisfied only whenoutcome.is_success AND outcome.is_explicit(engine.py). This closes bypass paths that never reach the parser's plain-prose rung — notably the spawn path's status-only SUCCESS and unparseable structured output.
is_explicit is therefore load-bearing at the gate check, not just observability metadata.
Any new Outcome producer must classify itself: set is_explicit=True iff the status comes
from an unambiguous verdict mechanism.
response_schema policy decision (corrected in independent review round 2).
Format ≠ verdict. Parseable schema output proves the model followed the requested
FORMAT; it does not prove the node asserted a VERDICT. Schema-parsed output is explicit
ONLY when it carries a recognized verdict, routed through the same verdict ladder as every
other path: a captured report_outcome tool call (authoritative), or a status field
whose value is a recognized StageStatus. Generic structured output — {"name": "Alice"},
{"assessment": "NOT CONVERGED"} — stays DERIVED (is_explicit=False): the node still
returns SUCCESS (ordinary schema nodes are unchanged), but a goal_gate=true schema node
cannot satisfy its gate with it. The original round-1 policy ("parseable schema output IS
explicit") was a false-success side door: a goal_gate structured-output judge returning
{"assessment": "NOT CONVERGED"} — or a name-extraction payload — would have shipped
success. Both structured-output paths (backend.py tool-loop and
DirectProviderBackend.run()) share one classifier
(backend._outcome_from_structured_output); empty or unparseable schema output also
stays is_explicit=False, so a goal_gate schema node fails closed in every non-verdict
case.
CodergenHandler string path. When a backend returns a raw string (the spec §4.5
CodergenHandler path — exercised by simple/custom backends and test doubles), a
goal_gate=true node's string is routed through the verdict-recovery ladder
(_parse_outcome): JSON verdicts are honored, plain prose returns RETRY. This implements in
our own handler the exact goal_gate check the walk-upstream note recommends for the spec.
Non-goal_gate string responses keep the unconditional-SUCCESS wrap (spec §4.5 preserved).
Spawn-path consistency. _outcome_from_spawn_result() returns is_explicit=False when
recovering from the orchestrator's completion status alone (no report_outcome, no JSON). A
goal_gate child that produces no final text and no report_outcome cannot satisfy its gate via
the spawn wrapper's status field alone — the gate layer rejects it.
Producer classification (every Outcome-producing path that can reach a goal-gate check):
| Producer | Verdict mechanism | is_explicit |
|---|---|---|
report_outcome tool call (tool-loop / direct-provider / spawn metadata) |
asserted by node | True |
Pure / fenced / embedded JSON verdict (_parse_outcome) |
asserted by node | True |
Tool node exit code (handlers/tool.py) — 0 and nonzero |
process exit code | True |
response_schema output carrying a verdict — captured report_outcome, or status field with a recognized value (backend._outcome_from_structured_output) |
verdict via the standard ladder | True |
response_schema output WITHOUT a verdict — generic data such as {"name": "Alice"}; also empty/unparseable |
format only, no verdict | False (gate fails closed) |
Plain-prose fallback (_parse_outcome) |
none — defaulted | False (+ RETRY for goal_gate) |
| Codergen string-wrap for non-goal_gate nodes (spec §4.5) | none — defaulted | False (not gate-relevant) |
Spawn status-only completion (_outcome_from_spawn_result) |
wrapper status, not node verdict | False (gate fails closed) |
| Empty response (any path) | none | False (FAIL) |
Tool timeout / missing tool_command / handler exception |
environment failure | False (FAIL — not gate-relevant) |
Human gate selection / freeform input (handlers/human.py) |
deterministic human action — cannot be LLM-defaulted | True |
Human gate SKIPPED (handlers/human.py) |
deterministic interviewer decision | True (FAIL) |
| Start / Exit / Conditional structural no-ops | deterministic structural SUCCESS — no LLM in the loop | True |
Fan-in ranking verdict (handlers/fan_in.py) |
deterministic aggregation over branch statuses | True |
Fan-in with no parallel.results (handlers/fan_in.py) |
environment/wiring failure | False (FAIL) |
Parallel join-policy verdict, incl. no-branch SUCCESS (handlers/parallel.py) |
deterministic counting rule over branch statuses | True |
Parallel branch exception / missing engine (handlers/parallel.py) |
environment failure | False (FAIL) |
Manager-loop stop/guard completion (handlers/manager_loop.py) |
the child's verdict | propagates child's is_explicit |
Manager-loop cycle exhaustion / config failure (handlers/manager_loop.py) |
environment failure | False (FAIL) |
Folder / pipeline (subgraph) node (handlers/pipeline.py) |
child pipeline's terminal outcome — CAN carry a defaulted LLM completion | propagates child's is_explicit (outcome returned verbatim) |
Shipped examples with goal_gate=true nodes (complete sweep of examples/):
| File | Gate node(s) | Behavior delta |
|---|---|---|
examples/patterns/task-runner.dot |
verify, verdict (parallelogram tool gates) |
None — tool exit codes are explicit verdicts; gates satisfied on exit 0 exactly as before |
examples/pipelines/practical/feature-build.dot |
integration_test (LLM, retry_target=self) |
Plain-prose completion now RETRYs instead of silently satisfying the gate |
examples/pipelines/02-plan-implement-test.dot |
implement (LLM) |
Same — explicit verdict (report_outcome / JSON) now required |
examples/pipelines/04-retry-with-fallback.dot |
implement, simple_implement (LLM) |
Same |
examples/pipelines/10-full-attractor.dot |
implement_backend, implement_frontend (LLM) |
Same |
examples/pipelines/practical/pr-review.dot |
generate_comments (LLM, no retry_target) |
Same; with no retry_target an unsatisfied gate ends the pipeline FAIL (see hazard note) |
examples/pipelines/practical/multi-lens-review.dot |
synthesize (LLM, no retry_target) |
Same |
examples/pipelines/practical/refactor.dot |
snapshot_tests (LLM, retry_target=self) |
Same |
examples/pipelines/practical/test-gen.dot |
write_tests (LLM, retry_target) |
Same |
For the LLM gate nodes above this is the intended breaking change: in the default Amplifier
backend the child session has the report_outcome tool available and is prompted to use it;
completions that end in bare prose now RETRY (then degrade to FAIL) instead of silently
recording success — which is the incident class this extension closes.
Tests affected and updated in this change:
| Test | Why affected | Resolution |
|---|---|---|
test_goal_gate_retry_clears_failures.py (3 tests, tool-node gates) |
tool exits lacked is_explicit |
fixed by handlers/tool.py (exit codes are explicit) |
test_pipeline_e2e.py::TestGoalGate::test_success_with_satisfied_gate |
SuccessBackend returned plain prose |
SuccessBackend now returns a pure-JSON verdict |
test_pipeline_e2e.py::TestGoalGate::test_retry_on_unsatisfied_gate |
RetryThenSucceedBackend Outcome lacked is_explicit |
double now sets is_explicit=True |
test_pipeline_e2e.py::TestSpecSmokeTest::test_step3_execute |
SuccessBackend plain prose on goal_gate node |
same SuccessBackend fix |
tests/test_backend.py:576 test_backend_plain_text_returns_success |
tests a non-goal_gate node | unaffected (plain prose → SUCCESS preserved) |
tests/test_backend.py:1096 test_parse_outcome_plain_text_returns_success |
_parse_outcome with no node arg |
unaffected |
tests/test_goal_gates.py |
MockBackend returns explicit Outcomes | updated with is_explicit=True on mock verdicts |
Plain-edge silent-hard-stop hazard: Observer and reporter nodes that have only plain
out-edges and no goal_gate=true are unaffected — they still get SUCCESS for plain prose
(spec §4.5 preserved). For goal_gate=true nodes with only plain out-edges, the RETRY status
will not traverse those edges (RETRY routes like FAIL for edge selection). Authors should
ensure goal_gate nodes have explicit condition="outcome=fail" or retry_target edges, or
use report_outcome / JSON verdicts to produce the expected routing signal. The lint sweep
(test_examples_lint_clean.py) and dot_graph validate catch isolated nodes and missing
fallback edges.
The canonical spec §4.5 default should change to: when a node carries goal_gate=true, a
plain-prose response (no JSON, no report_outcome) must NOT be recorded as SUCCESS. The
recommended upstream change is to add a goal_gate check in CodergenHandler before the
final SUCCESS fallback, returning RETRY (or FAIL with a clear message) instead. Until the
upstream spec adopts this, this extension documents the divergence.
backend.py: _parse_outcome()— fail-closed rule at the final rung;nodeparameter addedbackend.py: _outcome_from_spawn_result()— status-only success isis_explicit=Falsebackend.py: _run_with_spawn()— passesnode=nodeto_parse_outcomebackend.py: _run_with_tool_loop()— passesnode=nodeto_parse_outcome; no-text path now returns FAIL for goal_gate nodes (consistent with spawn path's empty→FAIL); non-goal_gate no-text keeps the spec §4.5 SUCCESS default; structured-output path delegates to_outcome_from_structured_output(verdict-shaped → explicit; generic data → derived)backend.py: _outcome_from_structured_output()— the single structured-output classifier shared by both backends (format ≠ verdict; see policy decision above)__init__.py: DirectProviderBackend.run()— passesnode=nodeto_parse_outcome; same no-text scoping; structured-output path delegates to the shared classifieroutcome.py: Outcome—is_explicit: bool = Falsefield addedhandlers/tool.py: ToolHandler.execute()— exit-code outcomes are explicit verdicts (is_explicit=Truefor both exit 0 → SUCCESS and nonzero → FAIL); timeout, missingtool_command, and handler exceptions remain non-explicit (no verdict was produced)handlers/codergen.py: CodergenHandler.execute()— goal_gate string responses are routed through_parse_outcome(verdict ladder + fail-closed); non-goal_gate string responses keep the spec §4.5 unconditional-SUCCESS wraphandlers/human.py— selections, freeform input, and SKIP are explicit (deterministic human/interviewer actions); agoal_gate=truehuman gate is satisfiablehandlers/start.py,handlers/exit.py,handlers/conditional.py— structural no-op SUCCESS is explicit (deterministic, no LLM in the loop)handlers/fan_in.py,handlers/parallel.py— aggregation/join-policy verdicts are explicit (deterministic rules over branch statuses); wiring/environment failures stay non-explicithandlers/manager_loop.py— stop/guard completions propagate the child'sis_explicit; exhaustion and config failures stay non-explicithandlers/pipeline.py— returns the child outcome verbatim, so the child'sis_explicitpropagates (a folder node's outcome CAN carry a defaulted LLM completion)engine.py: _check_goal_gates()— centralized gate enforcement:gate_satisfied = outcome.is_success and outcome.is_explicit. The gate DOES consultis_explicitdirectly; this is what closes the spawn status-only bypass and any future producer that forgets to classify itselfengine.py: _write_node_status()andhandlers/codergen.py: _write_status()—is_explicitis serialized into everystatus.json(flat + iteration-scoped) and everytrace.jsonlrecord, making it durable audit data rather than an in-memory-only flag
This extension is additive — it implements the canonical spec's own run-dir layout contract (§5.6 requires per-node
prompt.md/response.md) and adds worker-session event persistence the spec does not specify. It also FIXES a spec self-contradiction; see the walk-upstream note below.
Worker sessions inside a pipeline run were write-only compute: they thought, acted, and vanished. Three separate post-mortems (one on the 2026-07-28 external incident that also motivated §25, two on internal runs) all dead-ended on the same missing evidence:
- The node's full final response survived only as a ~200-char scrap
(
notes="Plain text response: {output[:200]}"/last_response[:200]), because the codergen handler wroteresponse.mdonly when the backend returned a string — and the productionAmplifierBackendspawn path always returns anOutcome, early-returning past the write. Diagnostic analyses produced by pipeline nodes were cut off mid-sentence. - The
session_idrecorded instatus.jsonwas a dangling pointer: noevents.jsonlortranscript.jsonlexisted anywhere on disk for spawned worker sessions (foundation's spawn path never persists; see walk-upstream note). "Which tools did the worker call?" — the first question of every wrong-but-plausible audit — was unanswerable.
1. Full-response durability (Outcome.response_text → response.md).
_parse_outcome() (backend.py) now carries the verbatim child output on
Outcome.response_text — set on every return path, before any truncation. The codergen
handler writes it to <stage_dir>/response.md on the Outcome path, closing the early-return
gap. The field is a file-write concern only: it is NOT serialized into status.json,
trace.jsonl, or context_updates, and the ≤200-char last_response context truncation is
unchanged (context economy working as designed).
2. session_id in the codergen early-writer. The engine's status writers already
serialize session_id; the codergen handler's own _write_status() now does too, so the
Outcome path never leaves a status record without its join key.
3. Real worker-session event persistence. The worker's actual event stream is captured and persisted per session:
amplifier_module_loop_pipeline.worker_observabilityexposes aContextVar(current_worker_sessions_dir); the codergen handler sets it to<stage_dir>/sessionsfor the duration of each backend call (try/finally-reset, task-local so parallel branches cannot cross-talk).hooks-pipeline-observability— already mounted into the parent session by the attractor-core behavior, and composed into every spawned worker session byPreparedBundle.spawn's parent+child bundle composition — registers aSessionEventPersisterthat appends each received event to<stage_dir>/sessions/<session_id>/events.jsonl. Thesession_idcomes from the event payload itself: the amplifier-core kernel merges it into every event viahooks.set_default_fieldsat session construction.- Persisted events (curated for forensic value; streaming deltas excluded):
session:start,session:resume,session:end,prompt:submit,prompt:complete,tool:pre,tool:post,orchestrator:complete. Record shape is the standard session observer shape —{"event": <name>, "timestamp": <utc-iso>, "data": {...}}— one JSON object per line, append-only.
These are the events the worker's own orchestrator/kernel emit as they happen (e.g.
loop-agent's tool:pre/tool:post at tool-execution time) — captured, not reconstructed.
An earlier design that fabricated a 3-event ledger after the child completed was rejected in
review: a synthetic record cannot answer "which tools did the worker call?" and amounts to a
second, invented session store.
Starting from ONLY the run dir:
<logs_root>/<node_id>/status.json → read "session_id"
<logs_root>/<node_id>/sessions/<session_id>/events.jsonl
→ the worker's real event stream
<logs_root>/<node_id>/response.md → the worker's full final response
Session persistence is a session concern owned by amplifier-foundation: ordinary sessions
persist events.jsonl/transcript.jsonl under ~/.amplifier/projects/<project>/sessions/<id>/
(amplifier_foundation/session/store.py, finder.py). On this stack that idiom never fires
for pipeline workers: PreparedBundle.spawn() has zero persist call sites —
DEFAULT_SESSIONS_ROOT is never written for spawned children; they are ephemeral by
construction. The right long-term home for worker persistence is therefore foundation's spawn
path (an upstream change to a different repo). Until that exists, this bundle captures the
real event stream at the seam it owns — the hooks module it already mounts into every worker
session — and persists it in the run dir, which is (a) the canonical pipeline-scoped forensic
record (prompt.md, response.md, status.json already live there) and (b) durable in
CI/sandbox environments where $HOME is ephemeral. Standard file name, standard record
shape, real events — pointers stay resolvable without inventing a parallel session store.
Spec self-contradiction (flagged upstream): canonical spec §5.6 requires per-node
prompt.md and response.md in the run-dir layout, and its conformance checklist asserts
artifacts_exist(logs_root, <node>, ["prompt.md", "response.md", "status.json"]) — yet the
spec's own CodergenHandler pseudocode contains an
IF result is an Outcome: write_status(stage_dir, result); RETURN result early-return that
skips the response.md write for Outcome-returning backends. The shipped handler had
faithfully transcribed that self-contradiction. This extension implements the layout
contract; the spec's pseudocode should be corrected to match its own §5.6.
modules/loop-pipeline/amplifier_module_loop_pipeline/outcome.py—response_textfieldmodules/loop-pipeline/amplifier_module_loop_pipeline/backend.py—_parse_outcome()setsresponse_texton all return pathsmodules/loop-pipeline/amplifier_module_loop_pipeline/worker_observability.py— the ContextVar seam (new)modules/loop-pipeline/amplifier_module_loop_pipeline/handlers/codergen.py— Outcome-pathresponse.mdwrite, ContextVar set/reset,session_idin_write_status()modules/hooks-pipeline-observability/amplifier_module_hooks_pipeline_observability/session_events.py—SessionEventPersister(new), registered in the module'smount()
Fully backward-compatible and fail-safe:
- Existing pipelines run unchanged;
last_responsetruncation and spawn/continuity semantics (thread transcripts, CR-1 invariant) are untouched. - Persistence degrades to a silent no-op at every missing seam: hooks module not mounted →
no subscriber; loop-pipeline not importable in the mounting session → resolver returns
None; ContextVar unset (session not spawned by a codergen node) → no write; event withoutsession_id→ skipped. Persister handlers never raise into the session. response.mdis written only whenresponse_textis present; infrastructure-failure Outcomes (no child output) skip it.
This extension adds an artifact-contract enforcement check to the engine (per retry attempt, plus a final post-override backstop). It does not conflict with the canonical spec; the spec is silent on per-node artifact contracts. Nodes without
must_write=are completely untouched (opt-in).
The same engine gap has been patched at the graph level repeatedly — every guard hand-rolled after a live failure:
- pm_gate (
examples/patterns/task-runner.dot) — the postmortem node was observed returning SUCCESS without writing its report; a deterministic stub-guard gate now guarantees the file exists. - Verdict gates counting absence as refusal — in live runs of this pattern, a critique node silently ended on plain-text narration without writing its critique file; the deterministic verdict gate downstream (a grep against the missing file) counted the absence as a refusal, and a stall counter killed a run whose tree was ship-quality by direct re-verification.
- Historical postmortem stubs — the same "completed without writing" shape observed before pm_gate existed.
A box node's contract is often "this file now exists with real content." The
engine had no way to be told that. must_write= puts the cheapest evidence
check (the artifact exists AND is fresh AND is non-trivial) where every graph
gets it for free, instead of every author rediscovering the trap live.
A node may declare must_write=<path> as a node attribute. After the handler
returns a non-FAIL outcome, the engine runs a three-axis post-execution check:
- Existence: the file at
<path>must exist. - Freshness floor (REQUIRED):
artifact.mtime > node_start_wall(strictly greater than;time.time()snapshot taken immediately before the handler runs). A pre-planted file whose mtime predates OR equals the node start time FAILS even if it has content — presence alone is exactly the hole this contract closes. The equality case is rejected explicitly: an adversary (or a coarse-resolution filesystem) can set an artifact's mtime viaos.utimeto match the recorded start time, bypassing a>=check. - Non-trivial: the artifact must contain at least one non-whitespace byte. An empty file or a whitespace-only file does not satisfy the contract.
The check runs in two places:
- Per-attempt, inside the retry ladder (
execute_with_retry): a completed attempt (SUCCESS / PARTIAL_SUCCESS) that violates the contract consumes a retry attempt exactly like a RETRY outcome — the same shape as the fail-closed goal-gate verdict retries (§25). When attempts are exhausted, the violation becomes a loud FAIL with a clearfailure_reasonnaming the violated axis, and the node routes through its normal failure edges (retry_target,condition="outcome=fail"edges, etc.). - As the engine's final backstop, after all outcome overrides: the same
check runs again AFTER the
auto_statuspromotion and thecontinue_on_failoverride, so no override can convert an artifact-contract violation into a silent success.
If the handler already returned FAIL, the check does not run (no double-wrapping of failure reasons).
must_write= paths follow the same resolution rule as requires=:
- Absolute paths are used as-is.
- Relative paths are resolved against
context.target_dirif set, falling back toos.getcwd().
The task-runner invocation sets --cwd <target_repo> and --param target_dir=<target_repo>, so .ai/postmortem/report.md in a postmortem node
resolves to <target_repo>/.ai/postmortem/report.md — which is the right
place. Pipeline authors must document which cwd is the anchor in their graph's
invocation comments to avoid the environment-lies class at the contract layer.
"Non-trivial" means: content.strip() is non-empty (at least one
non-whitespace byte). This is the floor. Quality (schema, verdict
structure, minimum size) is NOT validated — that remains graph policy.
- Retries: a
must_write=violation respectsmax_retries— and the mechanism is worth stating precisely, because a plain FAIL outcome is never re-attempted bymax_retriesin this engine (the retry ladder retries only RETRY outcomes and retryable exceptions; see spec §3.5). The contract is therefore checked per-attempt insideexecute_with_retry(): a completed attempt (SUCCESS / PARTIAL_SUCCESS) that violates the contract consumes a retry attempt exactly like a RETRY outcome, mirroring the fail-closed goal-gate verdict retries (§25). A no-write completion is precisely the flaky-failure class where an in-place retry helps — re-invoking the handler gives it another chance to produce the artifact. Withmax_retries=N, a never-writes node invokes its handler exactly1 + Ntimes before failing. When attempts are exhausted, the violation becomes a loud FAIL that routes through the node's normal failure edges —retry_targetandcondition="outcome=fail"graph-routing retries work as usual.allow_partial=truedoes not soften the exhausted FAIL to PARTIAL_SUCCESS (fail-closed). This holds on both exhaustion paths: the completed-attempt path (SUCCESS/PARTIAL_SUCCESS attempts that never produced the artifact) AND the RETRY-exhaustion path, where the ladder would otherwise manufacture aPARTIAL_SUCCESS("Retries exhausted, partial accepted")verdict — that manufactured verdict is checked against the artifact contract before it is returned. Retries exhaustedallow_partial+ no artifact is a loud FAIL: no artifact means there is nothing to accept partially.
- SKIPPED (DESIGN DECISION): SKIPPED means the node did not execute,
and the artifact contract applies only to completed executions — a
SKIPPED outcome passes through the check unconverted, in both the retry
ladder and the engine's final backstop. A legitimately-skipped
must_write=node (runs_on mismatch, failed dependencies, handler-side skip) is NOT converted to FAIL for lacking an artifact it was never asked to produce. The one deliberate asymmetry:auto_status=truepromotion (SKIPPED → SUCCESS) runs BEFORE the final backstop, so a promoted node counts as a completed execution and the contract applies to it — a node that ran, wrote no status, and wrote no artifact is exactly the narration-without-artifact class this contract exists to catch. - goal_gate: the FAIL outcome returned by the must_write check has
is_explicit=False(the node never asserted a verdict; the engine forced the FAIL). Agoal_gate=truenode whose must_write check fires cannot satisfy its own gate — correct, since it produced no artifact. - continue_on_fail: a
must_write=FAIL is non-overridable.continue_on_fail=truedoes NOT suppress it. The guarantee is by ordering, not a flag: the engine runs the must_write check as the FINAL backstop, after theauto_statuspromotion and thecontinue_on_failoverride, so any non-FAIL outcome that reaches the end of node processing without a fresh, non-trivial artifact is failed there. This also covers the adjacent side door: a must_write node whose handler FAILED for its own reasons and whose artifact was never written cannot be resurrected to SUCCESS bycontinue_on_fail=true— the backstop re-checks the artifact contract after the override and fails the node. A pipeline author cannot accidentally (or intentionally) void the artifact contract by addingcontinue_on_fail=trueto a must_write node.
The mtime-floor alone leaves a narrow window where an external process writes
a content-bearing file after node start but before the check runs, and the
node's own session never wrote. Session attribution — correlating the
write to this node's session_id — is the preferred closing mechanism: it
retires the sibling-plant class entirely (a sibling node pre-writing another
node's declared artifact inside the window). The mtime floor
is the minimum shipped here; session attribution is deferred. The test
suite (test_case4_delayed_replant_informational) documents this residual
honestly: a delayed replant passes under the mtime-only implementation, by
design and on the record.
examples/patterns/task-runner.dot postmortem node declares
must_write=".ai/postmortem/report.md" as the first consumer. The
pm_gate guard remains in place until the contract is live-proven; it is
not removed in this change (per the task's non-goal).
What this contract retires, and when — honest on both halves:
- Already retired by the freshness floor (shipped here): guard glue that
exists only to wipe STALE prior-round artifacts before a node re-executes.
When a node is visited again on a graph cycle, a fresh
node_start_wallis recorded for that execution — a file left over from a previous round has an older mtime and cannot satisfy this round's contract. - Retires when session attribution lands (deferred): guard glue against SIBLING PLANTS — one node pre-writing another node's declared artifact during the delayed-replant window. The mtime floor cannot distinguish that write from the node's own.
- Retires only after the contract is live-proven: the pm_gate stub
in
examples/patterns/task-runner.dot— subsumed by the postmortem node's own fail-closed artifact contract (must_write=is declared on that node in this change, but the deterministic guard is deliberately kept; see Exemplar adoption).
What does NOT retire:
- Verdict parsing stays graph policy. A write-first skeleton ending
VERDICT: PENDINGpasses everymust_write=axis (fresh, authored, non-trivial) yet carries no shippable verdict — the task-runner's anchored^VERDICT:grep still refuses it. Presence and quality are separate contracts by design:must_write=moves the presence half into the engine; the quality half (anchored verdict parsing, consensus, stall counting) remains graph policy forever.
All existing pipelines are unaffected: the check is opt-in. No existing node
in the shipped examples declares must_write=; the DOT parser already passes
unknown attributes through to node.attrs unchanged. The only new behavior
is for nodes that explicitly add the attribute.
modules/loop-pipeline/amplifier_module_loop_pipeline/must_write.py—check_must_write(node, outcome, node_start_wall, context): the shared contract check (new module, soengineandretrycan both use it without a circular import).modules/loop-pipeline/amplifier_module_loop_pipeline/retry.py— per-attempt check insideexecute_with_retry(): a completed attempt that violates the contract consumes a retry attempt like a RETRY outcome; exhaustion returns the loud FAIL (allow_partialdoes not soften it).modules/loop-pipeline/amplifier_module_loop_pipeline/engine.py—node_start_wall = time.time()recorded before handler execution;_check_must_writedelegates to the shared check and runs as the FINAL backstop (Step 2.7, after the auto_status and continue_on_fail overrides).specs/EXTENSIONS.md— this entry.examples/patterns/task-runner.dot— postmortem node gainsmust_write=".ai/postmortem/report.md"(exemplar adoption).modules/loop-pipeline/tests/test_engine_must_write.py— unit tests for the adversarial battery cases, relative-path resolution, non-trivial semantics, retry semantics (1 + max_retrieshandler invocations, retry-then-write success, allow_partial and continue_on_fail interactions), backward compat, and the council-amendment battery (RETRY-exhaustion manufactured-verdict veto both directions, SKIPPED pass-through both levels, auto_status-promotion asymmetry).modules/loop-pipeline/tests/test_retry.py— exhaustion telemetry truth: thepipeline:stage_failedevent'sfinal_statusalways matches the returned outcome (stringallow_partial="false", partial acceptance, and must_write-vetoed partial).docs/CONTRACTS.md,docs/DOT-SYNTAX.md,docs/DOT-AUTHORING-GUIDE.md,context/engine-semantics.md— retry-ladder truth stated wheremax_retriesis glossed (the ladder retries RETRY outcomes, retryable exceptions, and must_write violations; a plain FAIL is never retried in place), plus the continue_on_fail behavior-change sentence.docs/reports/2026-02-20-nlspec-dod-gap-analysis.md— dated errata note for the §11.5 "retried on RETRY or FAIL outcomes | PASS" row.
What: manifest.json (written by the engine at run-directory creation, Spec §5.6) now
includes two additional provenance fields:
{
"graph_name": "...",
"goal": "...",
"start_time": "2026-08-03T00:00:00+00:00",
"node_count": 3,
"edge_count": 2,
"engine_version": "0.1.0",
"engine_commit": "abc1234..."
}engine_version— theamplifier-module-loop-pipelinepackage version string fromimportlib.metadata. Today this is the staticpyproject.tomlvalue ("0.1.0"); it becomes discriminating when the package adopts release tags.engine_commit— the resolved git commit hash from PEP 610direct_url.json, written by uv for git installs. For editable/dev installs wheredirect_url.jsonis absent or carries no commit, the value is"unknown"— stamped honestly rather than guessed.
The standalone runner augments the manifest after each engine run, including a
failed run, with runner_version, runner_commit, and provider fields. Runner
version and commit use the same install-time metadata / PEP 610 mechanism and use
"unknown" when that identity is unavailable. provider is the runner API/CLI
selection (DOT node-level provider attributes remain the routing authority). One
writer per field — no races.
Why: Incident 2026-07-28: the run directory could not self-describe what code produced it. The incident analysis had to reconstruct engine identity from install history. In a fast-moving repo, "which engine produced this run?" is the first triage question; this extension makes the run directory answer it durably. Any cross-run comparison tooling likewise needs per-run code provenance to be meaningful.
Honesty contract: "unknown" is the correct value when identity cannot be determined
from install-time metadata without fabricating. A fabricated provenance field is worse
than an honest gap — stamp "unknown" over guessing.
Compatibility: Fully backward-compatible. The five legacy fields (graph_name, goal,
start_time, node_count, edge_count) are unchanged. The new fields are additive.
Existing manifest consumers (dashboards, tests reading manifest.json) continue to work.
Runner-engine compatibility assertion: The pipeline-runner package now includes a
startup compatibility assertion (compat.py) that checks for required engine symbols before
any node runs. The chosen shape is a compat-assert (not a pinned dep or single-package
collapse) — see compat.py for the tradeoff rationale and the amplifier-foundation @main
deferral note.
This extension is NOT in the canonical attractor spec. The canonical spec has no feedback-accumulation vocabulary. This extension should be proposed upstream: the mathematical heart of the attractor (retry-with-accumulated-critique is descent, not re-flip) is a spec-level claim that deserves a spec-level mechanism. Until then, this extension documents the behavior here.
depends-on: none
upstream action: declining, reason:
strongdm/attractorhas had no commits since 2026-03-17, has issues disabled, and its own open community spec-correction PRs (#9, #10) have sat unmerged for 4+ months — filing there would not land. The divergence is tracked here instead.
What: A node may declare feedback_from="<critic_node_id>" to establish an engine-enforced
feedback accumulation contract. On every loop_restart edge traversal, the engine:
- Reads the named critic node's output from the just-completed iteration's
node_outcomes(BEFORE clearing them). - Prepends an iteration label:
"Iteration N critique: <text>". - Appends the labeled entry to an accumulated channel stored in context under the internal key
feedback.channel.<target_node_id>(e.g.feedback.channel.generatefor a node namedgenerate). Each target node gets its own channel key, preventing feedback leakage when multiple generator nodes each declare a different critic in the same pipeline. - Trims the channel to at most
MAX_CRITIQUES = 5entries (oldest-first drop — the curation bound). - Composes the channel into a newline-joined string and writes it to the plain context key
prior_critiques_<target_node_id>(e.g.prior_critiques_generate), making it immediately available for$prior_critiques_<target_node_id>substitution (e.g.$prior_critiques_generate) inpromptattributes on the next iteration. Delivery is guaranteed: if the target's prompt does not reference the placeholder, the codergen handler appends a labeled critique-history block automatically before variable expansion (feedback.py:ensure_feedback_placeholder()). The placeholder controls WHERE the history appears, never WHETHER it appears — forgetting it cannot silently sever the feedback loop. - Writes the accumulated channel to a durable artifact at
<logs_root>/feedback/<target_node_id>.md, overwriting it each restart so it always reflects the current window.
The critic node's output is resolved in this order: context_updates["tool.output"] (full stdout of
a tool node) → context_updates["tool.last_line"] → outcome.notes (codergen summary) →
outcome.failure_reason (if the critic itself failed — still informative feedback).
Why: The mathematical heart of the attractor is descent: a retry without critique of the prior
attempt is a coin re-flip (same distribution, new sample); a retry with accumulated critique is
descent. Before this extension, that load-bearing behavior hung on prose: the generator node's prompt
said "check .ai/feedback/ for prior guidance" — invisible to the engine, unverifiable at run time,
silently lost when a prompt was edited, and dependent on the model choosing to comply every iteration.
One bad day — the exact perturbation the basin exists to absorb — and the loop degraded into an
infinite re-flip with a nicer name, indistinguishable from convergence until the budget died.
feedback_from= converts every retry loop from hoping into descending. Whether feedback reaches the
next iteration is now a property of the graph structure, not of model obedience on a given day.
Curation / token discipline: The channel is bounded to MAX_CRITIQUES = 5 entries; each entry
is truncated to MAX_CRITIQUE_CHARS = 500 characters with a […truncated] suffix. Token cost per
iteration: at most 5 × 500 = 2 500 characters of injected critique — well within typical prompt
budgets. The critique node itself is the primary curator: pipeline authors write the critique node's
prompt to emit a single highest-leverage observation per iteration (the "Pyramid Summary" pattern in
convergence-factory.dot). The window bound is a safety net, not the primary curation mechanism.
An unbounded append channel becomes a stagnation attractor — early wrong ideas crowd out corrections;
accumulated critique becomes context poisoning. The bound prevents this.
Injection carrier: prior_critiques_<target_node_id> (e.g. prior_critiques_generate) is a
plain (non-dotted) context key. The substitution machinery
(handlers/codergen.py:_expand_variables, P7 block) expands only plain keys from context in prompt
attributes. Dotted keys (e.g. feedback.channel.<node_id>) work in tool_command but NOT in prompts
— context/engine-semantics.md §4. The internal accumulation channel uses the dotted key
feedback.channel.<target_node_id> precisely to avoid prompt expansion; the injected key
prior_critiques_<target_node_id> is plain precisely to enable it. Pipeline authors MAY reference
$prior_critiques_<target_node_id> in their prompt attribute — e.g. $prior_critiques_generate
for a node whose id is generate — to control placement. When the placeholder is absent, the
codergen handler appends a labeled block carrying it before expansion, so the same substitution
path delivers the history either way (declaring feedback_from= is sufficient on its own).
Timing contract: collect_and_inject_feedback() (feedback.py) is called at loop_restart
time, AFTER the critic node has completed (its output is in node_outcomes) and BEFORE
node_outcomes.clear() erases it. The injected prior_critiques_<target_node_id> key survives the
restart because context_updates are intentionally left untouched by the loop_restart block
(engine.py Step 6 comment). This is the natural carrier: feedback is another context write that the
restart intentionally preserves.
Attribute placement: feedback_from= is declared on the target node (the generator), not
on the loop_restart edge. This makes the dependency explicit in the graph: the generator node
declares which critic it listens to. Multiple target nodes can each declare different critics.
Backward compatibility: Fully opt-in. Nodes without feedback_from= are completely untouched —
zero change in behavior. The file-based .ai/feedback/ convention used by existing pipelines
continues to work. The engine channel is additive: pipelines can use both simultaneously.
Walk-upstream note: The canonical spec has no feedback-accumulation vocabulary. This extension
should be proposed upstream: "feedback must accumulate across iterations" is a spec-level claim about
what makes iteration a descent rather than a re-flip. The attractor lint tool can grow a
topological rule: "outer loop without a feedback_from= channel on any generator node" — a
statically checkable warning that a loop may be re-flipping rather than descending.
Implementation locations:
amplifier_module_loop_pipeline/feedback.py—collect_and_inject_feedback(), the collection and injection logic, andensure_feedback_placeholder(), the prompt-side delivery guarantee (analogous tomust_write.py)handlers/codergen.py: execute() step 1— callsensure_feedback_placeholder()on the raw prompt before variable expansionengine.py: run() Step 6 (loop_restart)— callscollect_and_inject_feedback()BEFOREnode_outcomes.clear(), then continues with the existing restart sequencemodules/loop-pipeline/tests/test_feedback_mechanism.py— unit + integration testsexamples/patterns/convergence-factory.dot— canonical exemplar declaring the contract
Constants (tunables in feedback.py):
MAX_CRITIQUES = 5— maximum channel depth (oldest-first drop when exceeded)MAX_CRITIQUE_CHARS = 500— per-entry character cap (truncated with[…truncated])PRIOR_CRITIQUES_KEY_PREFIX = "prior_critiques_"— prefix for the per-target plain injection key (canonical; full key =PRIOR_CRITIQUES_KEY_PREFIX + node_id, e.g."prior_critiques_generate")_CHANNEL_KEY_PREFIX = "feedback.channel."— prefix for the per-target internal dotted key (canonical; full key =_CHANNEL_KEY_PREFIX + node_id, e.g."feedback.channel.generate")PRIOR_CRITIQUES_KEY = "prior_critiques"— the unscoped key name from the initial design. Never written by the engine; retained so tests can assert it is never written (regression guard for per-target scoping)_CHANNEL_KEY = "feedback.channel"— the unscoped channel name; same never-written guard
30. Ledger Entry for PR #120's Observability Trio: attempt_count, Generalized failed_step, cycle_index, emit_node_events, Exception-Driven stage_retrying, and _branch_id Scoping
This is a ledger entry, not new work. PR #120 (commit
fb9fbe5, "epic #371 observability trio") shipped the contract additions described below without a corresponding entry in this file, in violation ofPRINCIPLES.md's requirement that "new event contracts \u2026 [require you to] add or update a spec extension document in the same PR that lands the implementation. Implementation without a corresponding spec note is debt." The gap was found in an independent post-merge review; none of the behavior below is new, and nothing is broken \u2014 this entry pays down the documentation debt for work that already shipped. Credit for the implementation belongs to PR #120 (Ken Chau); this entry is written after the fact, by a reviewer, to close the gap the original PR left open.
1. Outcome.attempt_count: int | None \u2014 the real, 1-indexed attempt count consumed by the
retry ladder. None when the outcome never entered the ladder (e.g. the engine's must_write=
final backstop, or subgraph/branch execution, which has no retry policy of its own).
outcome.py:97\u2014 field declaration; docstring atoutcome.py:89-96states theNonecase precisely and notes SKIPPED outcomes ARE included (they pass through the ladder without looping within it).retry.py\u2014 populated on every return path ofexecute_with_retry(): the exception-FAIL paths (retry.py:238,:263), the must_write-clean success path (:277), the must_write-exhaustion FAIL (:307), the plain-FAIL return (:312), the SKIPPED return (:329), the manufactured PARTIAL_SUCCESS on retries-exhausted (:369), and the manufactured FAIL on retries-exhausted (:387).engine.py:742\u2014 surfaced on thepipeline:node_completeevent as"attempt": outcome.attempt_count or 1(falls back to1for outcomes that never entered the ladder, e.g. therequires=skip backstop). This is distinct from the pre-existing"attempt": 1atengine.py:510emitted onpipeline:node_start, which is a within-handler retry counter kept for backward compatibility \u2014 the two fields are not the same signal and consumers should not conflate them.engine.py:615-700\u2014 the twoOutcomereconstruction sites (auto_statuspromotion andcontinue_on_failoverride) carryattempt_count(andfailed_step) forward field-by-field instead of dropping them; the reconstructedOutcomeotherwise resetsis_explicitto its default so a masked/overridden result cannot silently satisfy agoal_gate=truenode's gate (see \u00a725).
2. failed_step generalized from ToolHandler-only to CodergenHandler. Previously the
structured failed_step payload (\u00a725's backward-compat inventory footnote; originally "Issue 10
/ analog of WS-4 Sub-fix C") was populated only by handlers/tool.py. handlers/codergen.py now
populates it too, on both its failure paths, with an LLM-appropriate shape:
{"prompt": <first 500 chars>, "response_tail": <last 2000 chars, "" not None>, "error": <str>}
capped at 8192 total bytes (_TOTAL_CAP_BYTES, codergen.py:268); when the encoded payload
exceeds the cap, response_tail is dropped first and replaced with
"verification_gap": {"log_filtered": True} (codergen.py:299-302), mirroring ToolHandler's
truncation-marker convention. response_tail is always a string, never None, matching
ToolHandler's stdout_tail/stderr_tail convention (outcome.py:82-83).
handlers/codergen.py:163-172\u2014 exception path:_build_failed_step(prompt=prompt, response_text=None, error=str(e)).handlers/codergen.py:206-215\u2014 goal-gate verdict-recovery path: when_parse_outcomereturns FAIL and nofailed_stepis already set, attaches the same shape with the actualresponse_textcaptured.handlers/codergen.py:271-304\u2014_build_failed_step(), the shared builder and truncation logic for both call sites.
3. cycle_index (0-based) on manager-loop and pipeline subgraph-completion records, giving
both handlers a common field name for "which repetition" without requiring a consumer to know
each handler's own on-disk numbering convention:
handlers/manager_loop.py:417-427\u2014_subgraph_runsentries gain"cycle_index": cycle - 1(the handler's owncyclecounter is 1-based; the on-disk{manager_node_id}_cycle_{cycle}naming is unchanged).handlers/pipeline.py:315-323\u2014 the analogous subgraph-completion record gains"cycle_index": _inv, already 0-based on that path; on-disksubgraph_{node.id}/subgraph_{node.id}__iter{N}naming is unchanged.
4. run_subgraph(..., emit_node_events: bool = True) \u2014 a new public keyword-only parameter
on PipelineEngine.run_subgraph() (engine.py:933-938). Previously run_subgraph() emitted no
pipeline:node_start / pipeline:node_complete events at all, leaving ManagerLoopHandler's
in-graph subgraph path (and any other direct caller) entirely dark. run_subgraph() now emits
both events for every node it executes, by default. ParallelHandler passes
emit_node_events=False for its branch engines (handlers/parallel.py:169,175) because it
already emits the equivalent events itself, tagged via_parallel=True; without the opt-out,
branch nodes would double-count in the timeline.
This parameter replaces a private _suppress_subgraph_node_events setattr flag from an earlier
iteration of the same change \u2014 the setattr approach required external code to mutate engine
state and save/restore it around a shared instance. The keyword-only parameter is the one
behavior-affecting piece of this ledger entry: the default changed from "emits nothing" to
"emits by default," which is new signal for any consumer already listening to
pipeline:node_start/pipeline:node_complete on an engine whose graph contains subgraph or
manager-loop nodes. Existing callers passing only (start_node_id, context=...) are unaffected
by the parameter's addition, and the wire shape of the emitted events matches the top-level
run() loop's node events (retry-ladder-only fields such as attempt fall back to 1, since
run_subgraph() has no retry policy of its own).
5. pipeline:stage_retrying on exception-driven retries. Before this change, retry.py only
emitted PIPELINE_STAGE_RETRYING for a RETRY-status outcome or a must_write= violation
(retry.py:290-297, :342-349); an exception raised by the handler itself retried silently.
retry.py:246-256 adds the same emission on the exception path, with "reason": f"exception:{type(e).__name__}" so a consumer can distinguish an exception-driven retry from a
status-driven one. The event only fires when attempt < policy.max_attempts (i.e. another
attempt will actually happen) \u2014 an exhausted exception path returns FAIL directly, as before.
6. _branch_id scoping conventions for child-engine event disambiguation, used consistently
by both nested-execution handlers:
handlers/manager_loop.py:379-382\u2014cycle:{manager_node_id}:{cycle}, prefixed with the parent's own_branch_id(if any) via>so nesting under a parallel branch stays disambiguated.handlers/pipeline.py:258-262\u2014subgraph:{node.id}, same parent-prefixing convention.
Both sites set child_engine._branch_id directly (an attribute read by _emit, not a new public
API) rather than threading a new constructor parameter through; this is consistent with how the
existing ParallelHandler branch tagging already worked and does not change any wire shape by
itself \u2014 it only prevents concurrent child-engine events (folder subgraphs under parallel
fan-out, nested manager-loop cycles) from being ambiguous about their source.
Additive on the wire. No existing status.json / pipeline:* event field was removed or
renamed. Outcome.attempt_count is a new dataclass field with a None default; existing
Outcome(...) call sites that do not pass it are unaffected. "attempt" on
pipeline:node_complete, "cycle_index" on the two subgraph-completion records, and the
generalized failed_step on CodergenHandler failures are all new keys in existing dict
payloads \u2014 a consumer that does not read them sees no change. pipeline:stage_retrying on
exception-driven retries is a new occasion to emit an existing event with its existing shape,
not a new field.
One behavior-affecting change: run_subgraph()'s default. Everything else in this entry is
purely additive (new fields on outcomes/events a consumer must opt into reading). The
emit_node_events default is different in kind: it changes what a silent method now does by
default \u2014 emitting pipeline:node_start/pipeline:node_complete for every subgraph node where
it previously emitted nothing. A consumer that hooks pipeline events on an engine driving a graph
with subgraph or manager-loop nodes will now see node events for that nested execution that it
did not see before. Any direct caller of run_subgraph() that needs the old silent behavior
should pass emit_node_events=False explicitly, as ParallelHandler does for its branch
engines.
modules/loop-pipeline/amplifier_module_loop_pipeline/outcome.py\u2014attempt_countfield (line 97).modules/loop-pipeline/amplifier_module_loop_pipeline/retry.py\u2014attempt_countset on every return path; exception-drivenpipeline:stage_retryingemission.modules/loop-pipeline/amplifier_module_loop_pipeline/engine.py\u2014"attempt"onpipeline:node_complete(main loop andrun_subgraph());attempt_count/failed_stepcarried through theauto_statusandcontinue_on_failOutcomereconstructions;run_subgraph(..., emit_node_events: bool = True)and its node-event emission.modules/loop-pipeline/amplifier_module_loop_pipeline/handlers/codergen.py\u2014 generalizedfailed_step(_build_failed_step()and its two call sites).modules/loop-pipeline/amplifier_module_loop_pipeline/handlers/manager_loop.py\u2014hooks=/cancel_event=wiring onto the childPipelineEngine;cycle_indexon_subgraph_runsentries;_branch_idscoping (cycle:{manager_node_id}:{cycle}).modules/loop-pipeline/amplifier_module_loop_pipeline/handlers/pipeline.py\u2014cycle_indexon the subgraph-completion record;_branch_idscoping (subgraph:{node.id}).modules/loop-pipeline/amplifier_module_loop_pipeline/handlers/parallel.py\u2014emit_node_events=Falseon branch-enginerun_subgraph()calls (avoids double-counting branch node events already emitted withvia_parallel=True).modules/loop-pipeline/amplifier_module_loop_pipeline/pipeline_events.py\u2014PIPELINE_STAGE_RETRYING(pre-existing constant; new emission occasion only).- Tests exercising this surface (added/extended in PR #120, unchanged by this entry):
modules/loop-pipeline/tests/test_retry.py,test_subgraph_runner.py,test_manager_loop.py,test_parallel_branch_observability.py,test_p8_continue_on_fail.py.
Tool-Command/Handler-Mismatch Rejection (Stricter-Than-Spec Admission)
This entry is a ledger entry for already-merged work, not new work. PR #134 (commit
d792807, "fix: validate DOT retry budgets", @robotdad) shipped twovalidate()-time structural checks without a corresponding entry in this file. Credit for the implementation belongs to the PR's author; this entry is written after the fact to close the ledger gap and to classify each half of the change honestly — they are not the same kind of change.depends-on: §2, §3 (this entry validates the exact attributes those extensions define:
default_max_retries/default_max_retryand node-levelmax_retriesinheritance)upstream action: not applicable to the retry-parsing half (conformance restoration, see below — canonical spec already requires this). Not applicable to the handler-mismatch half either: it is a strictly local admission-time narrowing that refuses a subset of graphs the spec would silently admit; it does not ask the spec to change, so there is nothing to propose upstream.
What shipped:
-
retry_budget_non_negative— a conformance restoration. The canonical spec declares both the graph-level default and the node-level override as typedIntegerattributes:default_max_retries(attractor-spec.md:139, also:1993) andmax_retries(attractor-spec.md:152, also:2010). Before this PR, the DOT parser silently coerced malformed values (int(val)truncated fractions like1.5→1, acceptedTrueas1since Python'sint(True) == 1, and raised an unhandledValueError/TypeErroron non-numeric strings instead of producing a diagnostic)._parse_non_negative_retry_count()(retry.py) and the new_check_retry_budgets()validation rule (validation.py) now reject negative values, booleans, fractions, and unparseable strings atvalidate()time with a namedERRORdiagnostic, for both the node attribute and both graph-level aliases (default_max_retry/default_max_retries). This is a restoration, not an extension: the spec already declares these attributes asInteger; the implementation previously accepted values the spec's own type never permitted, and silently mis-executed rather than diagnosing them. No spec change is needed and noupstream actionapplies. -
tool_command_requires_tool_handler— a stricter-than-spec admission rule. Canonical spec §4.5 (CodergenHandler,attractor-spec.md:656-705) never reads or references thetool_commandattribute at all; the spec is silent on it for a codergen-resolved node, which means a spec-conformantCodergenHandlersimply ignores atool_commandattribute sitting on a node it handles — the spec permits (by omission) a graph wheretool_commandis present but inert. This PR's_check_tool_command_handler()(validation.py) makes that shape a validationERROR: a non-emptytool_commandon a node whose effective handler resolves to a recognized non-tool built-in (codergen, conditional, start, exit, …) is now rejected outright, not silently ignored. This is a real narrowing, plainly stated: we now refuse to execute a graph the canonical spec would admit and run (just with the attribute quietly doing nothing). Unrecognized/customtype=/node_type=values are deliberately exempted (_effective_handler_type()returnsNonefor any unknown explicit type), preserving the custom-handler extension point — the rule only fires when the resolved handler is a recognized non-tool built-in, never for an unregistered extension type the runtime hasn't seen yet.
Why this framing matters: conflating the two would either overstate the retry-parsing fix
as a behavior change requiring upstream sign-off (it doesn't — the spec's own Integer type
already prohibited the values now rejected) or understate the handler-mismatch rule as "just
tightening validation" without naming that it refuses spec-legal graphs. Recording both
correctly is the point of this entry.
Compatibility: The retry-parsing restoration is backward-compatible for every graph that
was already supplying spec-conformant integer retry values; only malformed values that were
previously mis-executed (truncated, coerced, or silently defaulted via an uncaught exception
path) now produce a clear diagnostic instead. The handler-mismatch rule is a breaking
narrowing for the specific, narrow case of a tool_command attribute present on a node
resolving to a recognized non-tool handler — such a graph now fails validate() where it
previously ran with the attribute silently inert.
Implementation locations:
modules/loop-pipeline/amplifier_module_loop_pipeline/dot_parser.py: _set_graph_attr()— preserves the raw parsed graph-level retry default instead of coercing it at parse timemodules/loop-pipeline/amplifier_module_loop_pipeline/retry.py: _parse_non_negative_retry_count()— the shared non-negative-integer parser (used by both the runtimeRetryPolicyand validation)modules/loop-pipeline/amplifier_module_loop_pipeline/validation.py: _check_retry_budgets(),_check_tool_command_handler(),_effective_handler_type()docs/DOT-AUTHORING-GUIDE.md— documents both structural errors under "Static Lint Rules"- Tests:
modules/loop-pipeline/tests/test_dot_parser.py,test_retry.py,test_validation.py
This entry is a ledger entry for already-merged work, not new work. PR #106 ("attractor lint — five topological basin-lint rules + CLI") shipped claiming no
specs/EXTENSIONS.mdentry was needed. An independent post-merge audit judged that call "arguable, and I'd add one" — the discriminator for whether an entry is owed is the entry point a change lands on (advisorylint()vs. admission-gatingvalidate()/validate_or_raise()), not the severity of the findings it produces, and a separate advisory entry point is itself a fact about the implementation worth recording even though it widens nothing. This entry pays that down. Credit for the implementation belongs to PR #106; this entry is written after the fact.depends-on: none
upstream action: not applicable — canonical spec §7.4 explicitly permits custom/additional lint rules as an extension point (
extra_rulesparameter onvalidate(); see §7.3-7.4 of the canonical spec), andlint()composes exactly that permitted mechanism.validate_or_raise()(the admission-gating entry point) is untouched by this change. Nothing here diverges from or narrows the spec, so there is no upstream ask.
What shipped: A new lint() public entry point (validation.py) distinct from
validate()/validate_or_raise(), plus a CLI subcommand (attractor lint <file.dot>) that runs
it. lint() runs everything validate() runs (LINT-001–018, the structural rules, including the
two admission-gating rules from §31 above) plus five additional topological ("basin-lint")
rules that reason about cycle structure and handler semantics rather than per-attribute syntax:
- TOPO-001 (
ERROR) — dead conditional edge out of adiamond(ConditionalHandler) node:outcome!=success/outcome=failconditions on an edge out of a diamond can never fire, becauseConditionalHandleralways returnsSUCCESSunconditionally andFAILis fail-fast (never reaches a diamond via a plain edge). This was the root cause of 8 shipped examples carrying dead corrective edges for months before this rule existed. - TOPO-002 (
WARNING) — ambiguous multi-match on a tool node (staletool.last_line+outcome=failboth matching on a retry visit). - TOPO-003 (
WARNING) — acyclic graph (no corrective cycle at all); flags a candidate for "this should have been a recipe, not an attractor," while explicitly allowing deliberate one-pass pipelines. - TOPO-004 (
WARNING) — a cycle (SCC) with no explicitly-gated exit edge. - TOPO-005 (
WARNING) — a cycle whose continuation/exit rests solely on LLM say-so, with no deterministic (tool or human-gate) evidence gate on the cycle.
Why a separate entry point, not folded into validate(): the five TOPO rules are
judgment calls about pipeline design quality (is this graph shaped like a converging
attractor?), not about whether the graph is executable. validate_or_raise() — the
admission-gating entry point that decides whether a pipeline runs at all — is untouched;
every one of the five rules is reachable only through lint(), and only TOPO-001 defaults to
ERROR severity within lint()'s own exit-code contract (errors → exit 1, warnings → exit 0
unless --strict). This is exactly the kind of extension canonical §7.4 anticipates: additional
rules layered on top of, not instead of, the spec's own validation surface.
Compatibility: Fully additive. No existing validate() or validate_or_raise() caller is
affected; lint() is a new, separately-invoked surface. A pipeline that was runnable before
this PR remains equally runnable after it — attractor lint is an author-time advisory tool,
never consulted by the engine at run time.
Implementation locations:
modules/loop-pipeline/amplifier_module_loop_pipeline/validation.py—lint()entry point;_check_dead_conditional_edge()(TOPO-001) and the four sibling TOPO-002–005 checksmodules/pipeline-runner/amplifier_module_pipeline_runner/cli.py—attractor lintsubcommanddocs/DOT-AUTHORING-GUIDE.md— "Static Lint Rules (attractor lint)" section documents all five rules with fix examples- Tests:
modules/loop-pipeline/tests/test_topological_lint.py,modules/loop-pipeline/tests/test_examples_lint_clean.py
This extension DIVERGES from canonical spec §3.2. Canonical spec §3.2 step 6 (
attractor-spec.md:388-393) specifies: when no next edge is selected, return the last outcome unchanged if it isFAIL; otherwise returnOutcome(status=SUCCESS, notes="Pipeline completed")— a dead end is treated as a normal, successful pipeline completion regardless of whether the graph's author intended that node to be a true exit. Our engine instead hard-fails in every case: a dead end always terminates the pipeline withstatus=FAILand aPIPELINE_ERRORevent carryingerror_type=no_matching_edge, whether or not the last outcome wasFAIL. SeeSPEC_CONFORMANCE.mdATX-11 for the ledger entry andPRINCIPLES.mdfor the walk-upstream note.depends-on: none
upstream action: declining, reason:
strongdm/attractorhas had no commits since 2026-03-17, has issues disabled, and its own open community spec-correction PRs (#9, #10) have sat unmerged for 4+ months — filing there would not land. The divergence is tracked here instead.
This was an unledgered divergence: the engine has hard-failed on no-matching-edge since its
initial commit (verified against git log — the behavior predates and is unrelated to PR #66,
which only removed a duplicate resume-path check). A session audit found the gap and initially
recorded it with a pending DECIDE disposition (ALIGN vs. DIVERGE); that disposition was never
committed to SPEC_CONFORMANCE.md, so the decision has been open, undocumented, and — because
examples/pipelines/practical/bug-fix.dot's escalated node relies on exactly this hard-fail
behavior to report failure after writing its handoff artifacts (§8 backward-compat note in the
T0-4 restoration above notwithstanding) — load-bearing for a shipped exemplar.
The decision: keep the hard-fail. Never a silent fallback; always a traceable failure
reason. Rationale: a silent SUCCESS on an unrouted, dead-ended graph is the exact incident
class this engine exists to prevent. A real 2.4-hour pipeline run once exited status=success
with zero work product because a downstream signal was silently treated as acceptable
completion (see §25's incident motivation for the sibling case at the goal-gate layer). Applying
the spec's dead-end→SUCCESS rule at the main-loop level would reintroduce that same failure
mode one layer up: any graph with a genuinely unreachable or missing edge — an authoring
mistake, not a designed exit — would silently report success instead of surfacing the gap. A
loud, traceable failure (PIPELINE_ERROR error_type=no_matching_edge, plus
terminate_pipeline()'s failure_reason) costs an author a debugging session; a silent false
success costs an operator hours before anyone notices nothing happened.
No behavior change in this entry. The engine already behaves this way and has since its
first commit; this entry and the corresponding SPEC_CONFORMANCE.md update record the decision
that was made, not a code change.
Compatibility note — run_subgraph is intentionally NOT changed by this decision.
run_subgraph() (engine.py:917-925 at time of writing) returns the last outcome unchanged on
a dead end, matching the spec's permissive shape — this is deliberate: subgraph dead-ends are
the compositional path (a folder/sub-pipeline node's internal routing choices are its own
business), while the top-level main loop is where an unrouted graph is a run-ending authoring
defect. Any future change to run_subgraph's dead-end behavior is a separate decision, not
implied by this entry.
Implementation locations:
modules/loop-pipeline/amplifier_module_loop_pipeline/engine.py— main loop's no-matching-edge hard-fail (terminate_pipeline()call +PIPELINE_ERRORemission witherror_type=no_matching_edge, around the retry-target fallback check)modules/loop-pipeline/amplifier_module_loop_pipeline/engine.py: terminate_pipeline()— the sole construction path for a routing-termination outcome (seeAGENTS.mdcommon-pitfalls: never construct a freshOutcome(FAIL, ...)inline at this boundary — it dropsfailure_reason)context/engine-semantics.md§3 — documents both halves (main-loop hard-fail vs.run_subgraph's permissive dead-end) and is guarded against drift bymodules/loop-pipeline/tests/test_engine_semantics_doc_guard.py(D-200a/b/c)examples/pipelines/practical/bug-fix.dot(escalatednode) — the shipped exemplar that depends on this hard-fail to report failure after writing handoff artifacts
This is a bug fix restoring intended behavior, not a new extension. The spec (§3.3 Step 3) and this codebase's own
Node.id: str/Edge.to_node: strcontract (graph.py) have always treated node IDs as strings; nothing here changes that contract or adds a new capability.depends-on: none (this closes a gap between the canonical string-ID contract and the code that was supposed to enforce it; it does not build on or narrow any other ledger entry)
upstream action: not applicable — no spec change is needed and no compatibility-banner impact applies. This restores behavior the canonical spec's own string node-ID contract already required; the implementation previously accepted a type the contract never permitted and silently mis-routed or hard-failed instead of matching correctly.
Found by: a 6-lens council review convened while reviewing PR #133 ("preserve spawned agent
outcomes"). The bug is pre-existing and independent of #133 — present on main before and
after that PR — but #133's whole premise (making an explicit child report_outcome verdict
survive the session.spawn boundary reliably) increases how much pipelines lean on
suggested_next_ids surviving that boundary correctly, so the same latent bug becomes more
consequential once spawn-path explicit routing is the norm rather than the exception. PR #133
should merge after this lands; nothing in #133 introduces or worsens the bug below.
What was broken: edge_selection.select_edge() Step 3 compared
e.to_node == suggested_id with no type coercion. Outcome.suggested_next_ids travels through
several JSON-parsing paths (backend.py: _find_report_outcome_call, _outcome_from_structured_output,
_outcome_from_spawn_result, _parse_outcome's pure-JSON and embedded-verdict-recovery
branches) with no per-element type validation before construction. A spawned child (or any
report_outcome caller) that emits a bare-number ID in JSON — {"suggested_next_ids": [3]}
instead of {"suggested_next_ids": ["3"]}, an easy LLM slip — produced a Python int, and
"3" == 3 is always False. Depending on graph shape this manifested two ways:
- With a competing unconditional edge present: Step 3 silently failed to match, routing fell through to Step 4's weight/lexical tiebreak, and the pipeline silently ran the WRONG node. No error, no trace.
- Without one: Step 4 also found nothing (fail-fast / no eligible unconditional edge), and
the engine hard-failed with the generic
"No matching edge from node 'X'"message, which named neither the rejected suggestion nor the edges that existed — untraceable.
The fix:
edge_selection._coerce_suggested_id()— normalizes onesuggested_next_idsentry to its canonical node-ID string before comparison. Policy:strpasses through unchanged;int(excludingbool, aintsubclass but never a sane ID) is coerced viastr(value)(3 -> "3"); anything else (bool,float,dict,list,None, ...) is a genuinely malformed shape, not a type slip, and is rejected — logged as a warning naming the value and its type, and skipped so one bad entry doesn't prevent the rest of the list from being tried. Floats are deliberately NOT coerced:3.0is ambiguous against node"3"vs a literal node"3.0", and silently picking one would be exactly the "coerce into something plausible" failure mode this fix is designed to avoid for compound/ambiguous shapes.engine.PipelineEngine._no_matching_edge_reason()— theno_matching_edgefailure message (still prefixed"No matching edge from node 'X'"for backward compatibility with existing substring checks) now appends, when the outcome carriedsuggested_next_ids, the suggested IDs and the outgoing edge targets that actually existed, so a genuinely unresolvable suggestion (wrong ID, or a shape_coerce_suggested_idcorrectly rejected) produces a traceable diagnostic instead of a dead end.- The goal-gate-retry lookup at
engine.py(gate_result.suggested_next_ids[0]->self.graph.nodes[retry_node_id]) applied the same unguarded-comparison class of risk (an uncaughtKeyErroron a type-mismatched or unresolvable ID) even though it is currently protected by_check_goal_gates()'s own membership check on the sole path that constructs such an outcome today. Hardened to use the same_coerce_suggested_id+ membership check rather than a second, divergent rule for the same "suggested next ID" concept, degrading to a diagnosed failure instead of a crash if a future producer ever violates that invariant.
Grep audit (repo-wide): every other self.graph.nodes[...] dict index in engine.py
(edge.to_node, fan_in_node_id, start_node_id, gate_node_id) is keyed by an ID the engine
itself derived from the graph's own structure, or already validated via an in check
(_resolve_failure_retry_target) — none of them consume raw LLM/tool-reported IDs directly. No
other instance of the string/int boundary risk was found in the module.
What is unchanged: the JSON-parsing call sites in backend.py are untouched — the fix is
applied once, at the actual point of comparison, so it covers every current and future producer
of Outcome.suggested_next_ids uniformly rather than duplicating validation at each parse site.
Tests: modules/loop-pipeline/tests/test_spawn_suggested_next_ids_coercion.py — end-to-end,
through the real session.spawn path (AmplifierBackend._run_via_spawn -> _parse_outcome)
and the real PipelineEngine, not synthetic Outcome objects. Covers both graph shapes (with
and without a competing fallback edge) with an adversarial, JSON-round-tripped int payload.
What was retired: An unledgered dialect where non-shape=parallel, non-component nodes
with two or more simultaneously-matching conditional outgoing edges fanned out to ALL matching
targets in parallel (via select_all_matching_edges → _execute_parallel_fan_out), then
required a fan-in node. This behavior was never documented in this ledger.
What was restored: §3.3 single-edge selection — best_by_weight_then_lexical(condition_matched) —
is now the sole edge-selection path for non-shape=parallel, non-component nodes. When
multiple conditional edges simultaneously match, the engine deterministically picks exactly one:
the highest-weight match, with lexical target-id tiebreak.
What is unchanged: shape=parallel fan-out (extension #18) and component-node parallelism
(ParallelHandler) are untouched. These are spec-sanctioned explicit parallelism constructs.
Walk-upstream note (PRINCIPLES.md): This is a conformance restoration, not a new extension.
No spec change is needed. The canonical spec at §3.3 already prescribes single-edge selection;
this implementation now fulfills it. See SPEC_CONFORMANCE.md ATX-10 for the ledger entry.
Compatibility-banner note: The banner at the top of this ledger promises that community
.dot files written against the canonical spec continue to work without modification. While
the multi-match dialect was live, that promise was compromised for any spec-conformant graph
in which two conditional edges could simultaneously match (the spec prescribes one deterministic
successor; the engine ran both). With this restoration the engine's edge selection matches the
spec letter, and the banner is true again for edge selection. Graphs that deliberately relied
on the retired dialect must express parallelism explicitly (shape=component or shape=parallel,
extension #18).
depends-on: §25
upstream action: not applicable — spawned agent outcome transport is purely implementer-level semantics within the canonical spawn()/execute(...) -> str contract. This extension adds metadata transport to an already-implemented spawn boundary without changing the documented return contract or diverging from the canonical spec. No spec change is needed.
What: The loop-agent orchestrator transports a spawned child's semantic
report_outcome verdict through the canonical orchestrator:complete event without changing the
orchestrator's execute(...) -> str return contract.
Every AgentOrchestrator.execute() invocation emits exactly one orchestrator:complete event,
including initialization failures and raised exceptions. Its payload has two deliberately
separate layers:
{
"orchestrator": "loop-agent",
"status": "success | incomplete | cancelled",
"turn_count": 2,
"metadata": {
"report_outcome": {
"status": "success | partial_success | retry | fail",
"preferred_label": "optional",
"suggested_next_ids": ["optional"],
"context_updates": {"optional": "value"},
"notes": "optional",
"failure_reason": "optional"
}
}
}Top-level status is only lifecycle state:
success— natural completionincomplete— max-turn, tool-round, context, or awaiting-input limit; initialization/provider/ tool-loop exception (event is emitted before the original exception is re-raised)cancelled— cooperative or task cancellation
The semantic node verdict lives only in metadata.report_outcome; it does not redefine lifecycle
status. metadata is {} when no successful report belongs to that invocation, and interrupted
invocations do not promote a partial report. The mounted report tool's last_outcome is reset
before each invocation so state cannot leak between calls. turn_count is the per-invocation
number of attempted provider calls, computed from the cumulative provider-call counter.
Ordinary assistant tool-call batches retain configured parallel execution. A batch containing
at least one report_outcome call is the exception: every call in that batch executes
sequentially in the provider-declared order. This barrier is required because last_outcome is a
single semantic completion register. For multiple valid reports, the last successful declared
report wins. A later report that fails argument validation or execution does not erase the prior
valid report. After the complete declared batch finishes, any successful report_outcome call
terminates the current outer execute() invocation without another provider call or automatic
follow-up processing. Already-queued follow-ups remain queued; they are not cleared or consumed by
the terminal report path and may be processed by a later explicit execute() invocation.
A child process may emit both an explicit structured verdict (via report_outcome) and trailing
prose in its response. The precedence rule is explicit: structured report_outcome status
supersedes contradicting trailing prose. A spawned agent that returns status: fail in its
report-outcome metadata but then writes "all done, mission accomplished" as closing text is
recorded as FAIL; the documented verdict takes precedence over cheerful prose. This mirrors the
behavior already implemented in the direct tool-loop path where tool-command report_outcome
verdicts were always the canonical judgment. The spawn path now offers explicit verdict transport
to upstream callers who elect to consume it, placing both paths on equal footing for verdict
reliability.
This is additive at the spawn boundary:
execute()still returns the original final string unchanged.- Consumers that ignore
orchestrator:complete.metadatacontinue to see the documented lifecycle envelope. - Spawn consumers may opt into explicit verdict transport through
metadata.report_outcome; status-only spawn results remain non-explicit. - Parallel execution is unchanged for batches without
report_outcome.
modules/loop-agent/amplifier_module_loop_agent/__init__.py— per-invocation reset, exactly-one completion emission, lifecycle classification, provider-callturn_count, andmetadata.report_outcometransportmodules/loop-agent/amplifier_module_loop_agent/agent_session.py— provider-call counting, invocation termination reason, and thereport_outcomebatch ordering barriermodules/loop-pipeline/amplifier_module_loop_pipeline/backend.py— spawn-result precedence, semanticOutcomereconstruction, response/session preservation, and full-fidelity transcript continuitymodules/loop-agent/tests/test_orchestrator_completion.py,modules/loop-agent/tests/test_parallel_gating.py, andmodules/loop-pipeline/tests/test_backend_fidelity.py— contract tests