返回设计文档

ENGINE · 02/08

Campaign

Lifecycle、route、restart、halt、budget 和 App assets。

真相源
docs/DESIGN_ENGINES_CAMPAIGN.md
读取方式
构建时本地读取
规模
1,577 行

Status: active Scope: campaign-level execution contracts, app/subagent orchestrators, nested repair paths, ToolLoop callable surfaces, tools, and modules Orchestrator architecture details: DESIGN_ORCHESTRATOR.md Tracking: dakoolfrank/nexrur#36, dakoolfrank/nexrur#62, dakoolfrank/nexrur#129, dakoolfrank/nexrur#132, dakoolfrank/nexrur#133, dakoolfrank/nexrur#145, dakoolfrank/nexrur#198, dakoolfrank/nexrur#231, dakoolfrank/nexrur#249, dakoolfrank/nexrur#262, dakoolfrank/nexrur#264


1. Purpose

This document defines how campaign-level execution is described across the app contracts consumed by nexrur.

It answers five questions:

  1. What is a campaign-visible step?
  2. What is an orchestrator step entrypoint?
  3. What is the difference between app-level and subagent-local orchestrators?
  4. What belongs in toolloop.yml?
  5. 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:

  1. It cannot be meaningfully run without its parent subagent context.
  2. It is tightly coupled to a parent phase's input/output closure.
  3. Campaign should not route to it as a target subagent.
  4. 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.

Campaign lifecycle uses the same persisted checkpoint state machine for full runs and bounded Phase-by-Phase runs:

  • a new CLI invocation creates a new campaign_id, not automatically a new Trace or Cycle;
  • ordinary checkpoint recovery and exact execution inside the currently open owning app-level Subagent graph remain in the same Trace/Cycle;
  • an accepted repair/reroute or explicit re-entry into a mechanically closed graph seals the source Cycle and creates the next Cycle on the same Trace;
  • only an App-authorized fresh/replacement origin attempt creates a new Trace at Cycle 1.

Campaign owns accepted business repair/reroute policy. Orchestrator owns the generic recursive Subagent-closure proof and materializes an explicit reset. Neither layer may infer closure from an App name, UI status, or a bounded invocation's parent result. The next Cycle consumes only the immediately preceding Cycle's authorized handoff; older provenance may be referenced through that handoff but must not be discovered by history scanning.

Some Apps seal an immutable origin/source Product set at a declared business boundary. Campaign does not know the producer names or inspect Product content, but it must preserve the App's lifecycle classification:

  • a repair that reuses the sealed origin set may be authorized as same Trace / next Cycle, at or after the App-declared replay floor;
  • a route that replaces any sealed origin Product is a fresh/replacement attempt and must create a new Trace at Cycle 1;
  • Campaign must not downgrade a new-Trace disposition into an earlier same-Trace reset merely because the target path exists in the graph;
  • absence, conflict or an out-of-floor target fails closed before checkpoint mutation. Campaign and Orchestrator never infer the floor from App names.

An App route may also carry one bounded opaque transition_intent. Campaign validates it against the App contract and preserves it unchanged into the next Cycle handoff and cycle_execution_scope; it does not interpret the string. This is required when two actions share the same target Phase but have different identity and feedback semantics, such as repairing the current business object versus activating another object from the same sealed origin set. Missing or conflicting required intent fails closed.

When a route authorizes the next Cycle to replace an already adopted Asset, it must separately declare asset_replacement_authorized: true. This is a generic boolean capability, not an intent vocabulary: Campaign validates and preserves it, while never inferring it from transition_intent, target_step, reason code, Product content or Asset identity. Omission means false; conflicting copies in campaign control and routing decision fail closed.

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_step finds the app-level restart entry.
  • phase_path narrows the restart scope inside nested orchestrator graphs.
  • target_leaf is optional and, when present, narrows the final graph to a leaf step.
  • phase_path is 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_kind into the restart scope together with rerun_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 App-declared immutable origin/source lineage, possibly serving multiple sequential assets
asset_id    = one active business object inside that Trace; not the Trace identity
cycle_no    = one campaign-accepted continuous execution attempt inside the same Trace

Issues #262 and #264 record the design and runtime cutover for this identity model. Projection now records candidate adoption without moving the source Trace; Checkpoint keeps old Cycles read-only, and Campaign may replace the current Asset identity only through the verified adoption receipt returned by Orchestrator.

Campaign restart execution has one request-scoped substrate disposition:

continue     -> create the next same-trace Cycle and execute it immediately
prepare_only -> create and persist the next Cycle checkpoint, then return paused

continue is the default for unattended CLI/Campaign execution. An interactive adapter may explicitly request prepare_only; this does not change route, budget, target selection, or checkpoint semantics. After preparing the Cycle, Campaign must not invoke any phase. It returns orchestrator_paused with pause_reason=restart_cycle_prepared; a later explicit checkpoint resume owns the next phase execution. Replacement-trace actions are outside this same-trace Cycle disposition.

Next-Cycle publication is create-only (Checkpoint.save_new), matching explicit Orchestrator reset. If another writer has already published that target, return the existing stale_cycle_action conflict without replacing its bytes, reporting a prepared Cycle, incrementing the successful restart count, or selecting a different Cycle automatically. With the explicitly initialized #278 namespace index, these saves also join Orchestrator's namespace lock, source-version check and recoverable publication protocol. Campaign carries the checkpoint actually published by its execution into handoff, next-Cycle and replacement publication; it must not reload another writer's latest state to authorize its old result. The private source version is not an App payload/schema field. Uninitialized consumers retain the prior storage path; initialization and real deployment require an explicit maintenance operation. See Orchestrator design §6.1.1.

Tracking: nexrur#246.

Checkpoint-backed exact phase resume is outside Campaign attempt admission. Campaign validates only the checkpoint's standard trace/cycle and optional active asset fields. A coherent current Cycle with no asset_id, canonical_id or _current_asset_id remains candidate-unadopted even if an earlier Cycle or Projection has another active asset; that asset is not a fallback identity. After Orchestrator has verified the candidate-adoption receipt, matching standard active fields plus produced_asset_id identify the staged asset before active promotion. Graph-wide projection_identity is neither required nor trusted by Campaign; it is an action-scoped Projection request compiled at that call boundary. 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.

The caller's exact phase_path and optional completed-phase rerun authorization apply to that one same-cycle resume attempt only. If its result later carries a valid Campaign restart_requested, the accepted next-cycle transition expires both caller controls. Campaign records the authorized restart_scope through reset_from_step, clears the old explicit phase bounds, and lets Orchestrator resume the new checkpoint from that persisted scope. The old phase selection must never override the Campaign-authorized App or nested target in the next 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 is cycle-0001 and consumes the first cycle; therefore max_cycles: 1 permits no automatic restart cycle.
  • max_repairs_per_asset counts accepted same-asset repair transitions after the initial execution. It does not replace max_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 exceed max_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 create cycle-0002 or any later cycle directory.
  • When the current cycle has reached max_cycles, Campaign terminates the automatic restart request with restart_exhausted: true and restart_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-stable rule_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_exhausted with bounded mechanical state such as asset_identity_observed. It must not synthesize app conditions such as max_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_unadopted_trace is the canonical allowlisted generic Projection action. Projection validates and executes the atomic/idempotent archive move but does not interpret Campaign events, cycle counts, missing identity, or app policy. A failed dispatch or receipt validation remains visible as terminal_action.status: failed with the exact request/receipt and leaves the Trace unarchived. The pre-#262 whole-Trace archive action is not part of the active runtime contract. archive_asset_for_replacement_trace remains a separate asset-level action with a separate policy and receipt.
  • Every automatic same-trace action that advances cycle_no, including normal repair and resume_existing_operation, uses the same admission check. An explicit operator CLI --reset is a separate manual control operation and is not silently reclassified as an automatic Campaign repair.
  • An explicit replacement_trace starts a new trace at cycle-0001; its local max_cycles accounting therefore restarts with the new trace. Campaign-wide replacement/restart volume remains independently bounded by max_repairs_per_campaign.

Campaign owns campaign/trace/cycle identity at the control-plane boundary:

  • a fresh CLI run creates a fresh campaign_id;
  • a fresh CLI run without continuation identity creates a fresh origin-attempt trace_id; a checkpoint-backed continuation campaign attaches to its selected existing Trace/Cycle;
  • a new asset does not by itself decide the Trace boundary. An App-authorized candidate-advancement action may adopt it in the next Cycle of the same Trace when the sealed origin set is unchanged; replacing the origin set requires a fresh trace_id;
  • checkpoint recovery and exact Phase execution inside the open owning Subagent keep both trace_id and cycle_no;
  • an accepted repair/reroute, explicit reset, or re-entry into a mechanically closed owning Subagent keeps trace_id and advances cycle_no / cycle_segment;
  • a route with the ordinary action kind restarts within the current trace;
  • a route with action_kind: replacement_trace explicitly starts a new trace attempt inside the current campaign: campaign allocates a fresh trace_id, starts at cycle-0001, preserves parent_trace_id / previous_trace_id, and carries the bounded restart feedback into the new origin attempt. The new Trace may later adopt an asset only from its own App production result;
  • max_repairs_per_asset counts same-asset repair cycles and resets to zero for an App-authorized next asset, whether that asset is activated in the same Trace or in a replacement Trace; it must not inherit repairs consumed by the prior 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_campaign is an optional campaign-wide guard across ordinary restarts and replacement transitions. When omitted, it defaults to max_repairs_per_asset, preserving the bounded behavior of apps that do not opt into multi-asset replacement;
  • a new trace_id is allocated only for a fresh origin attempt without continuation identity or an explicit replacement_trace route. A changed asset identity alone never allocates a Trace. 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:

  1. Campaign allocates campaign_id and trace_id.
  2. Orchestrator writes the initial created checkpoint with every selected App step in pending state.
  3. Campaign persists its request-to-trace binding.
  4. Only then may the CLI emit campaign.accepted to 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.

For a live machine adapter, the CLI projects that durable acceptance through --events-jsonl as the first flushed campaign.accepted event. The event is a bounded transport of Campaign-owned identity, not a new identity allocator. A consumer binds it only to the exact child process it launched and must compare the later campaign.completed|paused|failed result with the accepted trace_id. Malformed or conflicting identity fails closed.

Graph deadline receipts remain a separate Orchestrator/supervisor contract. They may extend a process watchdog but must not allocate, select, or bind a Campaign Trace. ToolLoop runtime events, checkpoint directory order, mtimes and "latest Trace" scans likewise have no request-to-Trace binding authority.

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_lifecycle policy;
  • without archive_on_replacement_trace: true, the old asset remains in place;
  • with active_cardinality: one plus 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 standard config.canonical_id / config.asset_id / config._current_asset_id fields. Missing trace/cycle identity, conflicting asset fields, or an unauthorized mismatched bound 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 candidate adoption

An app production step may discover or materialize an active asset inside a Trace that already owns an immutable origin/source lineage. The step owns candidate legality and production semantics; Campaign owns the accepted transition and lifecycle control.

The boundary is:

upstream validated candidates
  -> app production selects/materializes one candidate
  -> production returns produced_asset_id
  -> Campaign verifies exact App transition authorization
  -> Projection adopts produced_asset_id for the current Trace/Cycle
  -> Trace-scoped origin Products remain in place
  -> 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 Cycle without an adopted asset.

Candidate adoption has these invariants:

  • a same-trace repair keeps the same provisional asset and advances the cycle;
  • without an exact App-authorized candidate-advancement intent, production must reject a different produced_asset_id before writing projection products;
  • an authorized same-Trace candidate advancement seals the prior Cycle, preserves the immutable origin identity, adopts exactly one new active asset in the next Cycle, and applies the App-declared feedback carry/reset policy. Campaign never chooses the candidate or infers advancement from a changed asset id;
  • 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
  -> seal the old Trace and all of its Cycle/adoption history
  -> allocate the replacement trace
  -> run the replacement entry in unbound discovery mode
  -> adopt only a produced_asset_id returned by the new Trace's 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 may quarantine provisional candidate roots only when the adoption ledger proves they were never authorized. A different asset id inside the same Trace is not by itself a contract violation: it may represent an App-authorized later candidate. Campaign does not infer authorization from app registries, directories or asset-id differences.

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 fresh manual CLI runs without continuation identity create many traces whose first cycle is cycle-0001. Repeated checkpoint-backed CLI runs may instead advance one existing Trace/Cycle phase by phase. Cycle increments only when Campaign accepts a restart/reroute or Orchestrator admits an explicit re-entry into a mechanically closed Subagent graph.

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.

The route target inside that feedback is diagnostic/control data, not the identity of every production phase in the restarted cycle. Campaign keeps the executable target only in the validated _restart_scope; when injecting the next-cycle feedback it removes stale target_step, target_subagent, and phase_path values from current_scope. The restarted phase compiles its own explicit Evidence identity. Routing values must never conflict with that phase-local identity after the restart target has been admitted.

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_code is the Diagnosis verdict and must match both diagnosis_result.result_code and campaign_control.result_code;
  • Product / nested leaf reason_code may appear only inside bounded phase_failure_facts; top-level restart_feedback.reason_code is forbidden;
  • source_step, target_step, and restart_from_phase_1=true are required;
  • a missing or conflicting field blocks as invalid_restart_feedback before 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, or campaign/_archived/patterns directories. 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_policy missing or invalid;
  • repair_plan missing 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, or empty-ok when fallback_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。

该约束针对 Campaign 的修复/路由授权,不得覆盖 Producer 已有的当前图继续许可。 对于唯一来自显式非阻断 Production issue 的诊断失败,Step6 可保留组合执行 partial,同时持久保留失败的 Diagnosis envelope 和 Campaign handoff_failed。 这不等于产生合法 continue_with_repair,也不授权 restart/repair;真实阻断事实 或缺失继续许可仍停止。完整门禁见 DESIGN_DIAGNOSIS.md §6.2.2(#279)。


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;
  • prepare produces validated intermediate input;
  • normalize consumes 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:

  1. Can campaign legitimately reroute to it?
  2. Can it restart from phase 1 without parent-only state?
  3. Does it need independent checkpoint/lifecycle/budget status?
  4. Is it owned by a physical skill local orchestrator?
  5. Is it instead a complex internal phase of a parent subagent?
  6. Is it a callable tool rather than a step?
  7. Should it be an app step_orchestrators entry, a nested phase_path repair block, a subagent-local phase, or a ToolLoop callable?

Decision:

AnswerContract location
App-level campaign targetapp orchestrator.yml step_orchestrators
Repair block inside a physical ownercampaign rerun_target.phase_path
Parent-only complex phaseparent subagent-local orchestrator.yml
Callable capabilityapp toolloop.yml under the appropriate owner surface
Business route/halt truthapp 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:

SectionResponsibility
metacontract identity, owner, and status
contractsdiagnosis catalog and route-key linkage
boundariesexplicit ownership limits
targetsexecutable vocabulary, explicit aliases, no-route results, fatal reasons, and hard guards
blocks.*.routesdiagnosis reason / intent -> executable target object
asset_registryoptional wrapper and opaque exclusion matcher

For action_kind=continue_with_repair:

  • continue_current_graph must be true;
  • restart_from_phase_1 must be false for the current control decision;
  • target_step / target_subagent / optional rerun_target identify the deferred repair scope and must pass the same target validation as restart;
  • Campaign records that scope in repair_plan but does not execute it in the current graph;
  • the matching diagnosis result_code must declare lifecycle=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_1 remains true at the selected executable leaf scope;
  • rerun_target.phase_path is required and must resolve to exactly one leaf;
  • Campaign emits the same action_kind in campaign_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_code declared halt_only belongs in no_route_results; it halts the current trace without selecting a restart target.
  • A substrate-wide credential/configuration failure may instead use a fatal_runtime_reasons entry. 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:

  1. Validate target_step against the app-owned campaign target vocabulary in the single campaign.yml contract.
  2. 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 current step_order;
    • <target_step>_evidence when the current local graph exposes an evidence entrypoint for the logical block.
  3. Use target_subagent only as an app-level owner fallback when the logical target cannot be resolved in the current graph.

Nested scope resolution:

  1. Read rerun_target.phase_path from the selected route, if present.
  2. The first path segment must name the same app entry as target_step or its resolved graph entry. Later segments must exist as nested pipeline.step_orchestrators entries under their parent graph.
  3. If rerun_target.target_leaf is present, it must exist in the final nested graph's step_order and must not bypass the final module orchestrator.
  4. 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 through campaign.yml.blocks.*.routes;
  • hard runtime guards;
  • continue_with_repair validation and deferred repair target registration;
  • campaign_control emission.

Runtime / checkpoint owns:

  • resume and replay;
  • step status;
  • artifact validity;
  • trace;
  • actual budget spent.

App run config owns:

  • max repairs;
  • max simulations;
  • optional Campaign restart-loop max wall clock;
  • target count;
  • App business-class budget values.

campaign.max_wall_clock_minutes is an optional ceiling on whether Campaign may begin another restart after the current graph returns. When declared, it must be a positive finite number and Campaign records and enforces it. When it is omitted, nexrur must not synthesize a default or reject the run. This value does not terminate or shorten the active orchestrator/ToolLoop graph; that graph remains governed by its compiled graph receipt.

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-checkRule
campaign.yml routes ↔ diagnosis catalogevery routed result_code must exist in diagnosis diagnosis.yml
catalog ↔ campaign.yml coverageevery repairable/routable/non_blocking_repairable result_code must have a route or explicit no-route policy
non-blocking lifecycle ↔ actionnon_blocking_repairable must map only to action_kind=continue_with_repair; blocking lifecycle must not use that action
async continuation ↔ actiononly 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 vocabularytarget_step / target_subagent must be legal substrate/app vocabulary
campaign.yml route metadataevery 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 vocabularyfatal runtime reasons and execution guards must be deterministic runtime facts, not semantic diagnosis rules
campaign.yml forbidden contentbudget values, lifecycle states, semantic escalation tables, and cognition assets are invalid
campaign.yml asset registry metadatawrapper 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_codesubstrate campaign hard fail,不 fallback "unknown route"
Runtime 期 continue_with_repair 没有可用 artifact refs 或合法 repair targetsubstrate 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

  1. Final campaign active asset is campaign.yml.
  2. Campaign does not judge semantic escalation. Diagnosis / LLM judges it.
  3. Budget values belong to the app run config consumed by nexrur-campaign.
  4. Resume and step status belong to checkpoint / trace / artifact validity.
  5. Business stop belongs to Diagnosis; runtime fatal stop belongs to campaign.yml.targets.fatal_runtime_reasons; Campaign executes the control.
  6. Diagnosis result_code to target is governed by campaign.yml.blocks.*.routes.
  7. Prompt B legality is governed by diagnosis-side Prompt B validator.
  8. A usable Production partial is not silently ignored: Diagnosis selects a non_blocking_repairable result_code, Campaign emits continue_with_repair, and fail2pivot remains partial while the current graph continues.
  9. An asset-producing app step owns candidate selection and returns exactly the resulting produced_asset_id; Campaign owns candidate adoption and lifecycle control but never reconstructs the candidate decision.
  10. Same-trace repair preserves a provisional asset. App-authorized candidate advancement may adopt a different asset in the next Cycle while retaining the sealed origin Trace. Active promotion happens only after the producing campaign-visible graph closes successfully.
  11. Single-active replacement is opt-in through the app projection contract. Apps without that policy keep existing multi-active/non-archiving behavior.
  12. 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.
  13. Campaign never hardcodes or infers an App registry wrapper or business classification. Optional campaign.yml.asset_registry metadata grants no registry read, identity, archive, quarantine, or resume authority.