Status: active Scope: campaign-level execution contracts, app/subagent orchestrators, nested repair paths, ToolLoop callable surfaces, tools, and modules Orchestrator architecture details:
DESIGN_ORCHESTRATOR.mdTracking:dakoolfrank/nexrur#36,dakoolfrank/nexrur#62,dakoolfrank/nexrur#129,dakoolfrank/nexrur#132,dakoolfrank/nexrur#133,dakoolfrank/nexrur#145,dakoolfrank/nexrur#198
1. Purpose
This document defines how campaign-level execution is described across the app contracts consumed by nexrur.
It answers five questions:
- What is a campaign-visible step?
- What is an orchestrator step entrypoint?
- What is the difference between app-level and subagent-local orchestrators?
- What belongs in
toolloop.yml? - When is something a module rather than a subagent or tool?
The short rule:
campaign sees subagent steps
app `step_orchestrators` bind subagent steps to recursive child graphs
campaign `phase_path` identifies nested repair blocks
subagent orchestrator orders internal phases
toolloop exposes callable tools
modules are internal complex phases owned by a parent subagent
1.1 Active Python call surface
Campaign is not an app-facing utility library. Its active Python surface is limited to the control-plane entrypoints actually consumed by nexrur:
nexrur CLI
-> load_campaign_contract
-> run_campaign
Fail2Pivot
-> load_campaign_contract
-> CampaignContractDrivers
-> campaign_control_from_diagnosis_result
App 不得直接 import Campaign 的 policy、counter、
budget 或 controller helper。它们只能声明 campaign.yml / Diagnosis assets,
由 Fail2Pivot 和 CLI 进入 Campaign。campaign.py 中为上述入口服务的
下划线函数和 driver 均为内部实现,不构成稳定公共 API。
run_campaign 的 prepare、execute、finalize 是一个连续 controller
事务。只有可以独立测试、参数边界明确且不改变 trace/cycle/restart 顺序的代码块
才允许提成内部 helper;禁止为了缩短单函数而复制 context 或另造第二套控制流。
2. Contract Surfaces
2.1 App-Level orchestrator.yml
The app-level orchestrator contract is the campaign step graph.
It answers:
Which campaign-visible steps exist?
In what order do they run?
What asset kinds does each step produce and consume?
Which steps are optional?
Which owner callable is the campaign-visible step entrypoint?
It does not answer:
Which internal phases run inside a subagent?
Which deterministic tools are available?
Which prompt/model is used by an internal phase?
Typical app-level shape:
pipeline:
graph_identity:
role: app
owner: example-app
product: null
step_order:
- ingest
- transform
- publish
step_deps:
ingest: []
transform: [ingest]
publish: [transform]
terminal_rules: {}
The app owns the step names and asset kinds. The substrate owns loading, validation, checkpointing, and execution mechanics.
2.2 App-Level orchestrator.yml::pipeline.step_orchestrators
step_orchestrators binds app-visible steps to recursive child graphs.
It answers:
For this app step, which child orchestrator contract should nexrur execute?
It does not answer:
What internal phases exist inside a subagent?
What deterministic tools are exposed to a ToolLoop runner?
What local module phases should be expanded inside a parent subagent?
The canonical binding is the app recursive graph:
pipeline:
graph_identity:
role: app
owner: example-app
product: null
step_order: [ingest, transform, publish]
step_orchestrators:
transform: .gemini/skills/transform-agent/_shared/engines/orchestrator.yml
If one physical subagent has multiple logical repair blocks, keep one app graph
step and describe the nested target in campaign rerun_target.phase_path.
2.3 Subagent-Local orchestrator.yml
A subagent-local orchestrator is the internal phase graph of one owning skill.
It answers:
Inside this subagent step, which phases run?
Which phase follows which?
Which phase artifacts exist?
Where do complex internal modules fit?
It does not make those internal phases campaign-visible.
Example:
pipeline:
graph_identity:
role: subagent
owner: example-app/transform-agent
product: transformed-output
step_order:
- upstream_digest
- preflight
- prepare
- index_refresh
- normalize_evidence
- normalize
In this example, index_refresh is an internal phase of the transform
subagent. It is not a duplicate app step; Campaign reaches it only through an
explicit parent-owned repair path when that path is legal.
2.4 App-Owned toolloop.yml
toolloop.yml declares callable tools for ToolLoop execution.
It answers:
Which callable tools can a ToolLoop runner or CallableToolExecutor invoke?
What are their argument schemas and output expectations?
What runner defaults apply to that callable surface?
It does not answer:
Which steps exist in the campaign?
Which orchestrator entrypoint entries are executable steps?
Which internal module phases must run in a subagent?
ToolLoop has two first-class execution modes:
- router mode: LLM selects tools dynamically;
- batch/callable mode: owner calls fixed tools through
CallableToolExecutor.
A tool registered in toolloop.yml is a callable capability. It is not a
campaign step unless the app also registers it in campaign contracts.
2.5 Runtime Registry
App runtime files can index the above contract paths for discovery and tooling.
They should not invent additional step ownership. Runtime registry entries must be consistent with the app-level campaign and orchestrator entrypoint contracts.
3. Subagent, Repair Block, Tool, and Module
3.1 Physical Subagent
A physical subagent is an app skill with its own local orchestrator, prompts, ToolLoop surfaces, schemas, and business semantics.
Typical physical shape:
.gemini/skills/<skill-name>/
scripts/skill_<name>.py
scripts/skills/*.md
scripts/toolloops/*.py
_shared/engines/orchestrator.yml
3.2 Logical Repair Block
A logical repair block is a campaign target inside one physical subagent graph. It is not a second app subagent and does not need an owner dispatcher.
Example:
target_subagent: transform-agent
target_step: normalize
rerun_target.phase_path: [transform, normalize]
The recursive orchestrator consumes the path and resolves the terminal logical block to its local evidence entry.
3.3 Tool
A tool is a bounded callable capability.
Tools are registered in toolloop.yml when they need substrate ToolLoop
governance, argument schemas, callable binding, router visibility, run-batch
execution, or audit/cache/timeout handling.
A tool must not own campaign state.
3.4 Module
A module is a complex internal phase owned by a parent subagent.
It may have its own:
- owner file;
- prompt assets;
- deterministic toolloop bodies;
- schema wrapper;
- diagnosis/campaign handoff shell for local evidence interpretation.
But it is not a campaign-visible step.
Use module status when:
- It cannot be meaningfully run without its parent subagent context.
- It is tightly coupled to a parent phase's input/output closure.
- Campaign should not route to it as a target subagent.
- Its artifacts are consumed as internal evidence or preparation for the parent subagent's next phase.
Modules should appear in the parent subagent-local orchestrator.yml. They
should not appear as duplicate app-level orchestrator steps. Campaign reaches a
nested module only through an explicit path owned by its parent subagent.
4. Decision Rules
4.1 Register in app step_orchestrators only when campaign can target it
A step belongs in app-level orchestrator.yml step_orchestrators only when campaign may legitimately:
- start it from phase 1;
- checkpoint it independently;
- mark it completed/failed/skipped at campaign level;
- route or reroute to it after diagnosis;
- budget it as a campaign-visible unit.
If these are false, it is not an orchestrator entrypoint step.
4.2 Use phase_path when one subagent has multiple repair blocks
If one physical subagent has multiple campaign-visible repair boundaries, keep one app-level subagent step and declare the nested repair path in the Campaign contract.
Example:
target_step: normalize
target_subagent: transform-agent
rerun_target:
app_step: transform
phase_path: [transform, normalize]
Campaign resets the app checkpoint at transform; recursive orchestrator then
consumes the remaining path and starts its child graph at the executable
normalize entry. It must not rerun earlier sibling blocks.
4.3 Keep modules inside the parent subagent local graph
If a component is only meaningful inside one parent subagent, keep it in that subagent's local orchestrator.
Do not promote it to app-level orchestrator entrypoint merely because it has its own owner file or prompts.
An owner file does not imply campaign visibility.
4.4 Register tools in toolloop.yml only for callable tool surfaces
toolloop.yml should contain:
- deterministic callable phase bodies;
- MCP/API/RAG/read tools;
- schema wrapper tools;
- ToolLoop router/batch callable surfaces.
It should not contain a module as a pseudo-subagent just because the module has multiple internal steps. If a parent owner needs to call module code directly as an internal phase, it may do so through that module owner without making the module a toolloop subagent.
If substrate governance around the call is required, the callable should be
registered under the parent subagent's tool surface, not necessarily as a new
top-level subagents.<module> surface.
5. Campaign and Fail-to-Pivot
Campaign routes to campaign-visible app entries only. A route may carry an
explicit nested rerun_target.phase_path into a repair block owned by that
entry, but the nested module does not become a second campaign-visible target.
Canonical path:
step failure
-> subagent Step 6 diagnosis_result
-> campaign campaign_control
-> continue | continue_with_repair | reroute | halt
Campaign 的业务路由输入只能是 diagnosis_result.result_code。Product 自己的
production_result.reason_code 是只读业务事实,只能随 handoff 原样保留;
Campaign 不得把它当作 result_code 的 fallback、别名或第二个 route key,也不得
把 result_code 写回 Product。
continue_with_repair 是正式 Campaign control,不是 fallback success,也不是
跳过 Diagnosis。它只适用于以下完整链路:
Production status=partial + real usable artifact_refs
-> Step6 phase_issue_facts
-> Diagnosis selects a scoped non_blocking_repairable result_code
-> campaign.yml maps that result_code to action_kind=continue_with_repair
-> campaign_control preserves repair target and continue_current_graph=true
固定语义:
- 当前 orchestrator graph 不被中断,不立即 restart 已经可用的 Production。
- Campaign 把待修复 target 写入现有
campaign_control/repair_plan/ checkpoint handoff;不得另造后台状态机或 side file truth。 - fail2pivot 返回
partial,由 orchestrator 的 completed dependency 语义继续 downstream;不得返回假 success。 - Diagnosis、issue refs 和 repair target 必须继续对 Admin 可见。
failed/blocked/ 无可用业务产物不允许使用本动作,仍走 restart / halt / escalation。
是否 non-blocking 由 Diagnosis result_code lifecycle 与 campaign.yml route 共同
声明。Campaign/Step6/Orchestrator Python 不得按 app result_code 硬编码放行。
The route's target_step must be a campaign-visible subagent, virtual
subagent, or app-level orchestrator entry declared by the app. A module is not
a valid top-level target_step unless the app has intentionally promoted it to
a virtual subagent with explicit app-orchestrator entries.
Nested modules can still be precise restart scopes. A route may include a
rerun_target.phase_path under a valid top-level target_step:
blocks:
publish:
routes:
REVIEW_RETRY_REQUIRED:
target_step: publish
target_subagent: publish-agent
restart_from_phase_1: true
action_kind: restart_from_phase_1
rerun_target:
app_step: publish
phase_path: [publish, review]
target_leaf: review_construct
Interpretation:
target_stepfinds the app-level restart entry.phase_pathnarrows the restart scope inside nested orchestrator graphs.target_leafis optional and, when present, narrows the final graph to a leaf step.phase_pathis not a new campaign-visible target. It must be executed only through the parent orchestrator boundary.
This lets a parent-owned module such as publish -> review or
transform -> normalize restart precisely without promoting the
module to app level and without bypassing the module orchestrator.
Every non-terminal path segment must resolve to a child orchestrator. Invalid
paths fail closed as invalid_restart_scope; runtime must not broaden the
restart to the whole parent subagent.
5.1 External Operation Continuation
resume_existing_operation is a same-trace Campaign action for one known,
already-submitted external operation. It is not a generic timeout retry and it
does not authorize another submit.
blocks:
operation:
routes:
PROVIDER_RESULT_PENDING:
target_step: owning-step
target_subagent: owning-subagent
restart_from_phase_1: true
action_kind: resume_existing_operation
rerun_target:
app_step: owning-step
phase_path: [owning-step, external_async_leaf]
Fixed rules:
- Campaign copies
action_kindinto the restart scope together withrerun_target; dropping it would turn continuation into an ordinary repair. - The route must resolve to one executable leaf inside the same trace. It does not create a replacement trace and does not broaden to the parent graph.
- The producing leaf must already have persisted a bounded provider handle and deterministic request identity. Orchestrator verifies that handoff fact; Campaign does not synthesize either value from the result-code name.
- A cooperative pending/deadline result with a known handle may use this action.
A ToolLoop hard timeout with no persisted handle is
operation_state_unknown, not a continuation signal. It must halt or enter explicit provider reconciliation; automatic resubmit is forbidden. - Business repair/replan uses its ordinary action kind and receives no prior provider handle, even when it restarts the same executable leaf.
resume_existing_operation is therefore distinct from
continue_with_repair: the former starts a new campaign cycle to poll one
known operation; the latter keeps the current graph running with a deferred
non-blocking repair plan.
Identity invariant:
campaign_id = one CLI campaign run
trace_id = one asset execution attempt inside that campaign, starting from the orchestrator first step
cycle_no = same trace / same asset / campaign-accepted restart attempt number
Checkpoint-backed exact phase resume is outside Campaign attempt admission.
When that checkpoint carries a complete trace-scoped projection_identity,
Campaign must preserve the unbound checkpoint identity even if Projection has
an unrelated current active asset. The active asset is not a fallback identity
for an already accepted unbound trace. Missing or incoherent trace/cycle
identity still fails closed, and an asset-scoped checkpoint must continue to
match the serial active target exactly.
After Orchestrator has verified bind_unbound_trace, the checkpoint may carry
a new asset-scoped produced_asset_id before that asset becomes active. Exact
resume must continue that checkpoint-owned staged identity; it must not replace
it with the unrelated current active asset. This exception requires the
checkpoint asset_id, produced_asset_id and complete asset-scoped
projection_identity to match exactly. An ordinary mismatched bound checkpoint
still fails closed as stale.
Campaign may transport the caller's explicit phase_path to Orchestrator, but
it must not reinterpret that request as a repair route, increment cycle_no,
copy Products, or inspect App phase names. The first resume execution therefore
uses the checkpoint's existing trace/cycle identity. Only a later Diagnosis /
Campaign decision may authorize a new repair cycle.
The transport boundary is intentionally small:
Campaign input: checkpoint_ref + generic phase_path
Campaign action: Orchestrator.resume(start_phase_path=phase_path,
end_phase_path=phase_path)
Campaign must not: mutate cycle identity before that call
An invalid or conflicting phase path fails closed in Orchestrator. Campaign does not broaden it to the root step and does not retry it as a new cycle.
Automatic cycle admission is budgeted before identity mutation:
goal_config.campaign.max_cycles, when declared, is the hard upper bound for automatically executed cycles inside one trace. The initial execution iscycle-0001and consumes the first cycle; thereforemax_cycles: 1permits no automatic restart cycle.max_repairs_per_assetcounts accepted same-asset repair transitions after the initial execution. It does not replacemax_cycles. The effective automatic same-trace allowance is bounded by both contracts: a restart is admitted only when its repair budget remains and its proposed cycle number does not exceedmax_cycles.- Campaign must evaluate the proposed next cycle before
reset_from_step, restart-feedback injection, checkpoint cycle mutation, checkpoint save, or Orchestrator resume. A rejected transition must not createcycle-0002or any later cycle directory. - When the current cycle has reached
max_cycles, Campaign terminates the automatic restart request withrestart_exhausted: trueandrestart_blocked_reason: max_cycles_exhausted. The result records the current cycle and configured limit so the stop is auditable. - An app may declare zero or more terminal actions through
goal_config.campaign.terminal_rules[]. Each rule owns an app-stablerule_id, one substrate Campaign event, exact matches against the mechanical event state, one allowlisted substrate action with bounded arguments, and a complete app-owned authorization. Thresholds, Product arguments, reason codes, and the decision to authorize an action remain app contract truth. - Campaign publishes generic lifecycle events such as
event: max_cycles_exhaustedwith bounded mechanical state such asasset_identity_observed. It must not synthesize app conditions such asmax_cycles_exhausted_without_asset_id, infer an app Product, invent an authorization, or default an app into terminal disposal. - Exactly one matching rule permits its authorized action to be dispatched.
Zero matches performs no terminal action. Multiple matches, an unknown action,
malformed arguments, missing authorization, or an incomplete action receipt
fail closed. The terminal result preserves the event, bounded state,
rule_id, exact authorization, action request, and action receipt for audit. archive_unbound_traceremains an allowlisted generic Projection action. Projection validates and executes the atomic/idempotent move but does not interpret Campaign events, cycle counts, missing identity, or app policy. A failed dispatch or receipt validation remains visible asterminal_action.status: failedwith the exact request/receipt and leaves the trace unbound.archive_asset_for_replacement_traceremains a separate asset-level action with a separate policy and receipt.- Every automatic same-trace action that advances
cycle_no, including normal repair andresume_existing_operation, uses the same admission check. An explicit operator CLI--resetis a separate manual control operation and is not silently reclassified as an automatic Campaign repair. - An explicit
replacement_tracestarts a new trace atcycle-0001; its localmax_cyclesaccounting therefore restarts with the new trace. Campaign-wide replacement/restart volume remains independently bounded bymax_repairs_per_campaign.
Campaign owns campaign/trace/cycle identity at the control-plane boundary:
- a fresh CLI run creates a fresh
campaign_id; - a new asset inside a campaign gets a fresh
trace_id; - returning an existing asset to any orchestrator step inside the same campaign
keeps the same
trace_idand advancescycle_no/cycle_segment; - a route with the ordinary action kind restarts within the current trace;
- a route with
action_kind: replacement_traceexplicitly starts a new trace attempt inside the current campaign: campaign allocates a freshtrace_id, starts atcycle-0001, preservesparent_trace_id/previous_trace_id, and carries the bounded restart feedback into the new attempt. It becomes a new asset attempt only when the app policy unbinds the previous asset; max_repairs_per_assetcounts same-asset repair cycles and resets to zero for the replacement asset; it must not inherit repairs consumed by the archived asset;- allocating a replacement trace does not by itself prove that the asset was replaced. If the app keeps the existing asset binding, the per-asset repair count is retained;
max_repairs_per_campaignis an optional campaign-wide guard across ordinary restarts and replacement transitions. When omitted, it defaults tomax_repairs_per_asset, preserving the bounded behavior of apps that do not opt into multi-asset replacement;- a new
trace_idis allocated only for a fresh CLI campaign, a new asset, or an explicitreplacement_traceroute. Runtime must not infer replacement from an app step name or from whether the target is the first graph step.
Fresh-run acceptance is a durable boundary, not a process-start notification:
- Campaign allocates
campaign_idandtrace_id. - Orchestrator writes the initial
createdcheckpoint with every selected App step inpendingstate. - Campaign persists its request-to-trace binding.
- Only then may the CLI emit
campaign.acceptedto a Web adapter or other machine consumer.
No App ToolLoop, LLM, MCP, or business step may start before this acceptance
checkpoint exists. A consumer can therefore use the returned trace_id
immediately without mistaking a local process receipt for Campaign truth.
replacement_trace starts from the route's resolved top-level target. It does
not support a nested phase_path: nested repair remains a same-trace restart so
successful parent state can be reused. An invalid replacement target or nested
replacement path blocks explicitly instead of broadening the restart.
Replacement identity and projection archival are separate contracts:
- campaign always owns the new trace identity;
- projection reads the app-owned optional
asset_lifecyclepolicy; - without
archive_on_replacement_trace: true, the old asset remains in place; - with
active_cardinality: oneplus archive enabled, projection archives the old asset across all app-declared required products before campaign starts the new trace; - after that opt-in archive, campaign removes the old explicit asset binding so the replacement entry step can produce a new asset identity;
- archive failure blocks the replacement as
replacement_trace_archive_failed; it must not start a new trace from a partially archived state.
Campaign does not discover active identity from an app business registry. Its single-active input comes only from projection active inspection and explicit run identity. An app-owned source/history file remains outside Campaign identity truth; Campaign must not infer that it is a runtime registry, use it for auto-resume, or mutate it during any run phase.
An App may declare consumer-registry metadata in campaign.yml.asset_registry:
optional records_key, one opaque exclusion.match_any field/value set, and an
audit reason. Campaign validates and carries this block only. It does not open
the registry, classify its records, clean it at startup/close, or use it to gate
production.
This block describes shape and exclusion only. It does not turn an App source/history file into projection identity truth, add read/mutation authority, or define a second lifecycle state machine. Malformed declarations fail contract loading; Campaign must not infer a wrapper from observed YAML contents.
Campaign merges configured and projection active identity before explicit
single-active resume. Before resume it compares that identity with the
checkpoint's config.canonical_id / config.asset_id. A missing, conflicting,
or mismatched checkpoint identity fails closed.
The orchestrator checkpoint asset_pool is an execution-artifact lineage, not
a Campaign asset registry. Campaign must not infer active identity from it and
must never prune or rewrite it during resume validation. Every app therefore
receives the same identity guard without losing nested step products required
by replay.
The substrate must not hardcode any app product names or impose one app's policy on another. An app opts in only through its projection contract.
Asset-producing steps and provisional binding
An app production step may discover or materialize a new asset during an otherwise unbound trace. The step owns candidate legality and production semantics; Campaign owns the resulting identity and lifecycle control.
The boundary is:
upstream validated candidates
-> app production selects/materializes one candidate
-> production returns produced_asset_id
-> Campaign binds produced_asset_id to the current trace as provisional
-> producing campaign-visible step closes successfully
-> projection may promote the provisional asset to active
produced_asset_id is an identity handoff, not permission for Campaign to
invent, rank, or reinterpret candidates. In particular, Campaign must not
recover rejected upstream candidates, scan product directories for a plausible
asset, or synthesize an asset id when the production result omits one. An empty
or invalid handoff fails closed and leaves the trace unbound.
Provisional binding has these invariants:
- a same-trace repair keeps the same provisional asset and advances the cycle;
- once a trace is bound, production must reject a different
produced_asset_idbefore writing projection products; it cannot turn a same-trace repair into an implicit replacement; - a repairable phase failure does not archive the asset merely because one cycle failed;
- the asset becomes eligible for active promotion only after the producing campaign-visible step completes its full local graph, including its schema and Step6 boundary;
- terminal abandonment or explicit replacement may archive provisional products only through the app-selected projection lifecycle policy;
- a production callable, nested orchestrator, or app stage must not perform Campaign archive, replacement, or trace allocation itself.
For an app that opts into active_cardinality: one, Campaign and projection
must enforce one active identity transactionally. An unbound discovery start
while another active asset exists is invalid unless the control action is an
explicit replacement. It fails as active_asset_replacement_required rather
than silently reusing, overwriting, or creating a second active asset.
On explicit replacement, the order is fixed:
validate replacement control
-> ask projection to archive the old asset across required product roots
-> require a complete projection archive result
-> allocate the replacement trace
-> clear the old trace's explicit asset binding
-> run the replacement entry in unbound discovery mode
-> bind only the produced_asset_id returned by production
All required product moves form one projection transaction. Products already in the same replacement archive batch count as complete, so retry is idempotent; missing required products or a move failure returns failed. Failure before commit leaves the prior active asset authoritative and blocks the replacement; Campaign must not continue from a half-archived state. Campaign coordinates the decision and consumes the result; it does not move directories itself.
The same projection transaction also quarantines trace-only candidate roots
written under the replaced trace but under a different asset id. These are
contract-violating provisional products, not additional Campaign assets. They
are archived as rejected_trace by exact trace identity; Campaign does not infer
them from app registries and does not adopt them as replacement candidates.
The policy declaration remains app-owned and separate from campaign.yml:
asset_lifecycle:
active_cardinality: one
archive_on_replacement_trace: true
archive_required_products:
- product-a
- product-b
campaign.yml declares the route and action_kind: replacement_trace.
Projection assets declare whether replacement archives products and whether
active cardinality is constrained. Checkpoint, trace, and projection metadata
record runtime state. None of these responsibilities authorize a second
app-local lifecycle state machine.
Therefore, repeated manual CLI runs naturally create many traces whose first
cycle is cycle-0001; cycle increments only when campaign accepts a restart
inside the same campaign-owned trace.
Campaign is the only layer that may turn a validated diagnosis result into restart / pivot / halt execution. Diagnosis suggestions, golden assets, and production prompts are advisory or generative; they do not execute control.
No "same input try again" is canonical. A retry/restart must carry a campaign
directive that changes the executable episode context, for example target
subagent, same-trace cycle retry, upstream
feedback, validated diagnosis result_code, or budget/lifecycle state. If campaign
cannot produce such a directive, it must halt, block, or escalate instead of
looping.
For a same-trace restart, campaign writes exactly one bounded diagnosis entry
to both checkpoint.config.upstream_feedback and
checkpoint.config.current_scope.upstream_feedback before saving the next
cycle checkpoint. The entry carries the canonical diagnosis result_code, source step, repair
target, and bounded Step 6 failure facts, including the app-owned opaque
diagnosis_facts already admitted by Step6. It must not copy full envelopes,
evidence bodies, or unbounded evidence-reference lists. A requested restart
without this feedback is blocked as missing_restart_feedback.
Campaign obtains this handoff only from the failed step receipt's fixed
control_envelope.handoff mapping. The mapping may contain the explicitly
declared campaign_control, diagnosis_result,
diagnosis_evidence_packet, and restart_feedback entries. Campaign must not
walk arbitrary checkpoint metadata or recursively search for matching key
names. Absence or invalid shape fails closed; it is not repaired by probing a
legacy nested result.
Before a normal same-trace repair/replan mutates the checkpoint, Campaign compares the
proposed bounded feedback with the single active feedback already consumed by the current
cycle. The comparison uses the existing restart-feedback identity: source, Diagnosis result,
target/scope, and bounded failure/diagnosis facts; presentation-only confidence and summary
wording are excluded. An exact consecutive match proves that the proposed cycle has no
executable evidence delta and must be blocked as
repeated_restart_without_progress before reset, cycle increment, checkpoint save, or resume.
The guard must not compare only result_code: the same result with different bounded physical,
semantic, or rejected-source facts is new repair evidence and remains restartable. It applies to
ordinary same-trace repair/replan actions. Explicit replacement_trace begins a separately
bounded attempt, while resume_existing_operation follows its provider-handle continuation
contract; neither is silently reclassified as an ordinary duplicate repair.
The restart handoff has one strict field-ownership contract:
- top-level
result_codeis the Diagnosis verdict and must match bothdiagnosis_result.result_codeandcampaign_control.result_code; - Product / nested leaf
reason_codemay appear only inside boundedphase_failure_facts; top-levelrestart_feedback.reason_codeis forbidden; source_step,target_step, andrestart_from_phase_1=trueare required;- a missing or conflicting field blocks as
invalid_restart_feedbackbefore repair-budget accounting, replacement-trace allocation, or archive mutation.
Campaign does not translate either code. It validates the two namespaces and transports the bounded Product facts alongside the independent Diagnosis result.
Some app routes need one earlier repair contract again after intervening
restarts. Such a route must opt in with
carry_feedback_until_trace_end: true. Campaign stores that bounded entry in
checkpoint.config.campaign_trace_feedback and mirrors the same bounded list to
checkpoint.config.current_scope.campaign_trace_feedback; later restart
injections preserve both views without making the list the active diagnosis.
The current-scope mirror is required because nested evidence handoffs propagate
bounded scope rather than the full campaign config. Production may consume this
explicit same-trace list, but Diagnosis continues to read only the single
current upstream_feedback entry. Campaign bounds and deduplicates the list;
deduplication requires the same source, Diagnosis result, target and bounded
failure facts. Two cycles may report the same result code with different physical or semantic
facts; those entries are distinct and must coexist within the bounded list.
Campaign must not collapse them using only (source_step, result_code). It never
scans prior checkpoints to reconstruct the list. A replacement trace starts from
a fresh goal and therefore does not inherit campaign_trace_feedback.
Bounded leaf facts remain semantically usable after both feedback construction
and checkpoint injection. In particular, a small rejection_facts list keeps
each item's identity, status, reason, and bounded gaps; depth compaction must not
replace those items with a bare truncation marker. Item-count and string-length
limits still apply, so this rule does not authorize full nested results.
For a replacement-trace restart, campaign writes the same bounded diagnosis entry into the new attempt's goal config before execution. It does not resume, reset, or copy the previous trace checkpoint.
Therefore, module boundaries are deliberately below campaign route granularity.
Their failures should be reported through the parent subagent's Step 6 evidence
and campaign handoff. Campaign may carry a nested phase_path as execution
scope, but the route still belongs to the parent campaign-visible target.
5.1 Campaign consumes contract artifacts, not raw evidence paths
Campaign owns:
- routing decisions;
- retry / halt / restart semantics;
campaign_control;repair_plan;- budget and lifecycle policy.
Campaign does not own:
- five-bucket source definition;
- artifact class definition;
- forensic/runtime boundary;
- producer artifact path ownership.
- case-grounded cognition, examples, counterexamples, or pattern reasoning.
Campaign may consume diagnosis_result and payload-validated evidence refs.
Campaign contract and run config must reference payload/schema registered
source/artifact ids, not declare new evidence bucket paths. Default campaign
execution must not read forensic_only artifacts.
repair_plan may reference artifacts, but those refs must point to
payload-registered artifacts. If campaign needs an artifact that is not
registered in payload/schema, this is an asset contract failure, not a campaign
fallback opportunity.
Hard rules:
- Five-bucket evidence and artifact contracts belong to payload/schema, not campaign.
- No campaign route/halt/escalation rule may introduce runtime evidence manifest paths.
- Default diagnosis/campaign evidence may consume only artifacts with
evidence_default=true. - Layer-B pool, raw dump, full candidate scan, and debug dump artifacts are not normal campaign inputs unless payload explicitly registers them as scoped non-forensic runtime artifacts.
- Schema validation must reject semantic boundary violations, not merely malformed YAML.
- Campaign must not consume campaign-local archived
campaign/_archived/examples,campaign/_archived/counterexamples, orcampaign/_archived/patternsdirectories. Case and pattern cognition belongs to diagnosis golden assets.
5.2 Campaign fallback_alarm handling
Fallback is an alarm, not recovery. fallback 是报警,不是恢复成功。
Campaign handoff and route failures must surface as failed control envelopes
with fallback_alarm; they are not opportunities to invent a successful route.
The following conditions must be failed, blocked, or handoff_failed and
carry fallback_alarm:
diagnosis_missing_result;campaign_route_missing;campaign_target_missing;campaign_handoff_failed;no_route_policymissing or invalid;repair_planmissing required fields or referencing invalid artifacts.
Campaign must not:
- turn missing route into an invented target and then
success; - convert handoff failure into
completed; - emit
success,completed,valid,passed, orempty-okwhenfallback_alarm.triggered=true.
Allowed fallback statuses are failed, blocked, degraded,
skipped-with-diagnosis, and handoff_failed.
continue_with_repair 不是 fallback status。它要求有效 Diagnosis reason、合法
Campaign route、真实可用 artifact refs 和可解析 repair target;缺任何一项都
必须显式 control failure,不得降级为 continue。
6. Nested Repair Example
This generic case illustrates the distinction between a physical subagent, logical repair blocks, and an internal module.
Physical subagent:
transform-agent
Campaign-visible logical repair blocks:
prepare
normalize
Internal module:
index-refresh
Why prepare and normalize are logical repair blocks:
- campaign may reroute to either block independently;
prepareproduces validated intermediate input;normalizeconsumes that input and produces the final normalized output;- both share one physical owner because their contracts are tightly coupled.
Why index-refresh should remain a module:
- it refreshes internal lookup state required by
normalize; - it is tightly coupled to the parent subagent;
- it should not be run by campaign without parent context;
- campaign should not route directly to it as a standalone subagent.
Correct shape:
app orchestrator:
ingest -> transform -> publish
transform local orchestrator:
prepare_evidence -> ... -> prepare_fail2pivot
-> index_refresh
-> normalize_evidence -> ... -> normalize_fail2pivot
campaign route to normalize:
[transform, normalize]
-> executable start normalize_evidence
In this model, a normalize restart reuses successful prepare and
index_refresh state, starts at normalize_evidence, and does not rerun the
earlier blocks.
7. Anti-Patterns
7.1 Registering a module as a campaign step by accident
Bad:
pipeline:
step_orchestrators:
index-refresh: .gemini/skills/transform-agent/modules/index-refresh/_shared/engines/orchestrator.yml
when index-refresh is only meaningful inside normalize.
Why bad:
- campaign can call it without the parent context;
- it may fail or skip for missing internal inputs;
- diagnosis/reroute target granularity drifts;
- module artifacts become campaign-level outputs.
7.2 Duplicating module ownership in toolloop.yml
Bad:
subagents:
index-refresh:
tools:
refresh_phase: ...
when index-refresh is not an app-level subagent and only its parent owner
should call it.
Preferred:
- keep module internals inside the module owner;
- call it from parent owner as an internal phase;
- if callable registration is necessary, register the callable under the parent subagent surface and describe it as an internal phase tool.
7.3 Treating app-level and subagent-local orchestrators as the same file
Bad:
app orchestrator contains every internal phase
Why bad:
- campaign checkpoint state becomes too granular;
- fail-to-pivot targets become unstable;
- modules appear reroutable when they are not;
- app-level lifecycle and subagent phase lifecycle collapse into one layer.
8. Review Checklist
When adding or moving a step, answer:
- Can campaign legitimately reroute to it?
- Can it restart from phase 1 without parent-only state?
- Does it need independent checkpoint/lifecycle/budget status?
- Is it owned by a physical skill local orchestrator?
- Is it instead a complex internal phase of a parent subagent?
- Is it a callable tool rather than a step?
- Should it be an app
step_orchestratorsentry, a nestedphase_pathrepair block, a subagent-local phase, or a ToolLoop callable?
Decision:
| Answer | Contract location |
|---|---|
| App-level campaign target | app orchestrator.yml step_orchestrators |
| Repair block inside a physical owner | campaign rerun_target.phase_path |
| Parent-only complex phase | parent subagent-local orchestrator.yml |
| Callable capability | app toolloop.yml under the appropriate owner surface |
| Business route/halt truth | app campaign contract |
9. Substrate Responsibility
nexrur owns:
- loading and validating app contracts;
- building orchestrator entrypoint registries;
- executing app-level orchestrator steps;
- checkpoint/replay/reset mechanics;
- ToolLoop callable/router/batch mechanics;
- diagnosis/campaign shells.
nexrur does not own:
- app step names;
- app logical repair-block choices;
- app module boundaries;
- app route target enums;
- app business artifact semantics.
The substrate should provide enough contract vocabulary to express these boundaries without forcing a module to masquerade as a campaign step.
10. Campaign Contract YAML Schema Governance
Campaign YAML 不是散文配置,而是 executable boundary contract。它 与 diagnosis contract 一起,由 substrate schema / payload contract 与 loader/validator 在加载阶段强约束。
详细 schema governance 规则见 docs/DESIGN_SCHEMA.md §11;diagnosis-side
对应章节见 docs/DESIGN_DIAGNOSIS.md §8.6。本节只声明 campaign boundary
必须遵守的硬约束。
10.1 Target Campaign Contract
The active Campaign contract is a single app-owned executable boundary file:
campaign.yml
Campaign is not a policy brain and not a semantic judge. Diagnosis / LLM judges
failure meaning and disposition intent. Campaign deterministically validates
that intent against execution targets and hard runtime guards, then emits
campaign_control.
campaign.yml answers only:
- can this validated
diagnosis_result.result_code/ intent land on an executable target? - is the target allowed by the app target vocabulary?
- which campaign-visible restart point should be used?
- does a runtime-fatal reason require immediate stop?
- are hard guards such as checkpoint / artifact validity satisfied?
campaign.yml must not answer:
- why the failure happened;
- whether a semantic escalation is warranted;
- whether an asset is worth continuing;
- how much budget a run owns;
- what lifecycle state machine should be maintained.
10.2 campaign.yml Contract Surface
Canonical shape:
meta:
version: "1"
kind: campaign_routing_contract
owner: example-app/campaign
status: active
contracts:
catalog_source: path/to/diagnosis.yml
route_key: reason_code
active_shape: blocks.<block>.routes
one_reason_code_one_route: true
boundaries:
not_reason_code_truth: diagnosis owns reason meaning
not_execution_graph: orchestrator owns execution order
role: map a validated reason to a deterministic campaign decision
targets:
valid_blocks: [transform]
valid_repair_targets: [normalize]
step_aliases: {}
no_route_results: [DIAGNOSTIC_ONLY_REASON]
fatal_runtime_reasons: [checkpoint_corruption]
execution_guards:
require_valid_target: true
require_catalog_reason: true
require_checkpoint_consistency: true
require_artifact_schema_validity: true
forbid_owner_local_reason_route: true
blocks:
transform:
routes:
NORMALIZATION_RETRY_REQUIRED:
target_step: normalize
target_subagent: transform-agent
restart_from_phase_1: true
action_kind: restart_from_phase_1
rerun_target:
app_step: transform
phase_path: [transform, normalize]
asset_registry:
records_key: optional-wrapper-key
exclusion:
match_any:
app-owned-field: [app-owned-value]
reason: app-owned-exclusion-reason
Allowed content:
| Section | Responsibility |
|---|---|
meta | contract identity, owner, and status |
contracts | diagnosis catalog and route-key linkage |
boundaries | explicit ownership limits |
targets | executable vocabulary, explicit aliases, no-route results, fatal reasons, and hard guards |
blocks.*.routes | diagnosis reason / intent -> executable target object |
asset_registry | optional wrapper and opaque exclusion matcher |
For action_kind=continue_with_repair:
continue_current_graphmust betrue;restart_from_phase_1must befalsefor the current control decision;target_step/target_subagent/ optionalrerun_targetidentify the deferred repair scope and must pass the same target validation as restart;- Campaign records that scope in
repair_planbut does not execute it in the current graph; - the matching diagnosis
result_codemust declarelifecycle=non_blocking_repairable.
The same route object therefore records a valid future repair target without creating a second route table. It does not authorize a concurrent retry, a new trace, or a hidden app-local repair loop.
For action_kind=resume_existing_operation:
restart_from_phase_1remainstrueat the selected executable leaf scope;rerun_target.phase_pathis required and must resolve to exactly one leaf;- Campaign emits the same
action_kindincampaign_control,routing_decision, and the derived restart scope; - missing/unknown operation identity is not downgraded to an ordinary restart.
10.2.1 External Authentication Halt Boundary
Campaign does not authenticate providers and does not own MCP retry policy. After MCP/toolhost has exhausted its declared bounded authentication retry, the leaf/orchestrator/Step6 chain supplies one cataloged raw reason to Diagnosis.
Campaign then applies these rules:
- An app-scoped provider/auth
result_codedeclaredhalt_onlybelongs inno_route_results; it halts the current trace without selecting a restart target. - A substrate-wide credential/configuration failure may instead use a
fatal_runtime_reasonsentry. This remains a deterministic runtime stop, not an app semantic route. - Halt/no-route handling must not increment repair count, open a new cycle, promote failed active artifacts, or broaden the target to the parent graph.
- Campaign must not route authentication failure back to the same source step. Repeating the provider call is owned solely by the bounded MCP retry policy.
- Credentials, cookies, auth headers, and provider response bodies are not
campaign inputs. Campaign consumes only Diagnosis
result_code, bounded failure facts, and catalog lifecycle metadata.
This boundary aligns with DESIGN_MCP.md and
DESIGN_DIAGNOSIS.md §8.3.1.
10.2.2 Restart Target Resolution
target_step in campaign_control is a campaign-visible restart point from
the app campaign contract. It is not required to be the literal leaf phase name
in the currently running orchestrator graph.
Campaign execution resolves restart in two layers: the app entry and the optional nested scope.
App entry resolution:
- Validate
target_stepagainst the app-owned campaign target vocabulary in the singlecampaign.ymlcontract. - Resolve that logical target to the current graph entrypoint:
- the target itself if it is present in the current
step_order; step_aliases[target_step]when the app declares an alias and that alias exists in the currentstep_order;<target_step>_evidencewhen the current local graph exposes an evidence entrypoint for the logical block.
- the target itself if it is present in the current
- Use
target_subagentonly as an app-level owner fallback when the logical target cannot be resolved in the current graph.
Nested scope resolution:
- Read
rerun_target.phase_pathfrom the selected route, if present. - The first path segment must name the same app entry as
target_stepor its resolved graph entry. Later segments must exist as nestedpipeline.step_orchestratorsentries under their parent graph. - If
rerun_target.target_leafis present, it must exist in the final nested graph'sstep_orderand must not bypass the final module orchestrator. - Invalid or non-resolvable nested scopes are control failures
(
invalid_restart_target/invalid_restart_scope), never fallback success.
Example:
target_step: reports
rerun_target:
app_step: reports
phase_path: [reports, penetration]
target_leaf: penetration_construct
This means: enter the app graph at reports, enter the reports child graph at
penetration, then optionally start the penetration child graph at
penetration_construct.
This keeps campaign routing at restart-point granularity. Production-internal phases, schema phases, Step6 evidence phases, and fail2pivot handoff phases are not campaign restart targets unless the app explicitly promotes them into the campaign target vocabulary.
Checkpoint handoff extraction must also respect this boundary: a failed
production leaf may not contain campaign_control. If the failed step has no
handoff, campaign scans the remaining checkpoint steps and consumes the first
campaign_control / diagnosis_result produced by the graph's Step6 /
fail2pivot handoff.
Forbidden content:
- budget numeric values, max repairs, max simulations, max cycles;
- semantic escalation tables;
- workflow lifecycle state machines;
- pending / curating / evaluating style state vocabulary;
- examples / counterexamples / patterns;
- diagnosis evidence rules;
- Prompt B validation rules;
- LLM prompt material.
10.3 Single Contract Rule
Campaign accepts exactly one App-owned campaign.yml. The active shape is
targets + blocks + optional asset_registry; unknown root fields fail closed.
Run-level numeric limits remain in the App run config.
10.4 Responsibility Boundaries
Diagnosis owns:
- evidence / history / budget / settings interpretation;
result_code;- confidence;
- repair / retry / stop / escalate / manual_review intent;
- non-blocking repair disposition for usable Production partials;
why_not_others;evidence_refs.
Campaign owns:
- deterministic validation of diagnosis intent;
- Diagnosis
result_code/ intent -> executable target projection throughcampaign.yml.blocks.*.routes; - hard runtime guards;
continue_with_repairvalidation and deferred repair target registration;campaign_controlemission.
Runtime / checkpoint owns:
- resume and replay;
- step status;
- artifact validity;
- trace;
- actual budget spent.
App run config owns:
- max repairs;
- max simulations;
- max wall clock;
- target count;
- App business-class budget values.
Campaign may consume budget_remaining / budget_exhausted as hard guard
facts, but it must not own budget numeric defaults.
10.5 Cross-File Validation Obligations
| Cross-check | Rule |
|---|---|
| campaign.yml routes ↔ diagnosis catalog | every routed result_code must exist in diagnosis diagnosis.yml |
| catalog ↔ campaign.yml coverage | every repairable/routable/non_blocking_repairable result_code must have a route or explicit no-route policy |
| non-blocking lifecycle ↔ action | non_blocking_repairable must map only to action_kind=continue_with_repair; blocking lifecycle must not use that action |
| async continuation ↔ action | only a known persisted provider handle may map to action_kind=resume_existing_operation; hard timeout without a handle must not use it |
| campaign.yml ↔ target vocabulary | target_step / target_subagent must be legal substrate/app vocabulary |
| campaign.yml route metadata | every route must declare target_subagent, restart_from_phase_1, and action_kind; continue_with_repair must also declare continue_current_graph=true; resume_existing_operation must declare one valid leaf scope |
| campaign.yml guard vocabulary | fatal runtime reasons and execution guards must be deterministic runtime facts, not semantic diagnosis rules |
| campaign.yml forbidden content | budget values, lifecycle states, semantic escalation tables, and cognition assets are invalid |
| campaign.yml asset registry metadata | wrapper key, classifier fields/values and audit reason are opaque App truth; Campaign validates/carries them without opening or mutating the registry |
Archived diagnosis targets.yml does not participate in runtime arbitration.
target_subagent must come from campaign.yml.blocks.*.routes; Python must not copy
target_step.
10.6 Hard-Cut Status
Issue #135 completed the hard cut. There is no alternate loader, normalized legacy layout, public compatibility alias, or filename inference. The caller passes the contract path explicitly; missing, conflicting, or malformed input fails closed.
10.7 Failure Semantics
| 时机 | 行为 |
|---|---|
| Load 阶段 schema 违反 | substrate loader hard fail |
| Load 阶段 cross-file 不一致 | substrate builder hard fail |
Load 阶段 repairable result_code 无 route 或 no-route 归属 | substrate builder hard fail |
Runtime 期 campaign 收到未登记 result_code | substrate campaign hard fail,不 fallback "unknown route" |
| Runtime 期 continue_with_repair 没有可用 artifact refs 或合法 repair target | substrate campaign control fail,不 fallback continue |
禁止: substrate loader / builder 对 schema / cross-file / coverage 不一致做 fallback success 或 silent skip。
10.8 Owner / Schema Wrapper Non-Compensation
Owner facade、schema.py、production code 一律不得:
- 在 owner 中补本地
result_code -> route表; - 在 schema wrapper 中做 region / source / reason 仲裁;
- 把 campaign contract 不一致包装成 "degraded ok" 后继续 longrun;
- 在 app 端替 campaign 选 fallback target;
- 在 Python 中补 semantic escalation、lifecycle state machine、budget numeric defaults。
详见 DESIGN_SCHEMA.md §11.8 与 DESIGN_DIAGNOSIS.md §7.3 Sovereignty。
修复路径: 改 campaign.yml / diagnosis catalog / run config / checkpoint
contract,不在 Python 中兜底。
10.9 Acceptance Answers
- Final campaign active asset is
campaign.yml. - Campaign does not judge semantic escalation. Diagnosis / LLM judges it.
- Budget values belong to the app run config consumed by
nexrur-campaign. - Resume and step status belong to checkpoint / trace / artifact validity.
- Business stop belongs to Diagnosis; runtime fatal stop belongs to
campaign.yml.targets.fatal_runtime_reasons; Campaign executes the control. - Diagnosis
result_codeto target is governed bycampaign.yml.blocks.*.routes. - Prompt B legality is governed by diagnosis-side Prompt B validator.
- A usable Production partial is not silently ignored: Diagnosis selects a
non_blocking_repairableresult_code, Campaign emitscontinue_with_repair, and fail2pivot remainspartialwhile the current graph continues. - An asset-producing app step owns candidate selection and returns exactly the
resulting
produced_asset_id; Campaign owns trace binding and lifecycle control but never reconstructs the candidate decision. - Same-trace repair preserves a provisional asset. Active promotion happens only after the producing campaign-visible graph closes successfully.
- Single-active replacement is opt-in through the app projection contract. Apps without that policy keep existing multi-active/non-archiving behavior.
- Campaign never treats an app business registry as projection active truth. Replacement continues only after projection confirms every required product is archived or already archived in the same replacement batch.
- Campaign never hardcodes or infers an App registry wrapper or business
classification. Optional
campaign.yml.asset_registrymetadata grants no registry read, identity, archive, quarantine, or resume authority.