Skip to content

Deployment-attributed fallback evidence

FallbackJournal is opt-in host-owned evidence for live compatibility routes. It does not switch dispatch or establish that engine is unreachable.

from symfonic.platform.fallback_journal import FallbackJournal
from symfonic.platform.observability import observability

fallback_services = observability(
    fallback_journal=FallbackJournal(
        "/var/lib/myapp/fallbacks.jsonl",  # provision this directory first
        release_version="11.0",
        deployment_id="staging-build-123",
    ),
)
# Inject fallback_services.fallback_telemetry_sink through the existing public
# AgentBuilder.fallback_telemetry(...) composition seam.

Use explicit non-sensitive build/deployment labels, never user input or secrets. release_version is caller-declared, not inferred from a release flag or installed package. Every JSONL journal row also carries package_version when the installed symfonic-core wheel provably owns the running code; see Installed package version. The callback emits kernel_fallback_attribution.v1 JSONL records with timestamp, version, deployment, capability, classified reason, entrypoint and legacy outcome. It does not copy prompts, raw refusal prose, tenant IDs or run identity. Existing kernel_fallback.v1 SQL metrics remain unchanged when configured alongside it: that SQL/Mongo metrics row has no package_version column and is not read by the deployment report, so version binding and the zero verdict cover the JSONL journals only.

The journal requires POSIX local-file locking. Each completed write is fsynced; multiple local writers coordinate through advisory locks. Remote filesystems, cross-machine sharing and crash recovery of an interrupted partial line are not attested. A partial final line is incomplete evidence, not a valid JSON record. Each event adds synchronous IO; operators provision capacity, retention and rotation. The callback opens/closes the file on every event and creates new files with mode 0600, but does not change permissions on an existing file.

Direct callback failures propagate, but the cutover board suppresses them so the legacy turn continues. The host-owned sink atomically snapshots (failed_attempts, in_flight_attempts, capture_epoch) before suppression. The epoch is a new random identifier for each sink instance, so a restart or sink replacement changes it. fallback_services.fallback_capture_snapshot is None when no counted sink was installed. An in-flight journal write can still fail, and a fallback can be between board entry and sink entry, so zero counters alone are insufficient. A healthy dispatch does not prove a successful journal write. Operators must establish journal health independently. Absence of records cannot prove zero fallback without population, observation-window, admission/refusal and completeness evidence. CORE-24 retains those obligations.

Opt-in invocation dispatch capture

Install DispatchOutcomeJournal through AgentBuilder.dispatch_outcomes(sink). The existing SymfonicAgent.dispatch_outcome_capture_snapshot remains a read-only three-tuple (failed_attempts, in_flight_attempts, capture_epoch). For deployment evidence, use dispatch_outcome_capture_counts: an atomic four-tuple with successful sink calls as delivered_count in position four. It returns None when no sink is installed; never replace this with invented zero counters. Normal sink exceptions increase the failure count without changing the turn result. A blocked callback remains in-flight until it exits. The epoch belongs to the observer instance, so rebuilding the agent changes it. Declarative SubAgentSpec children built by the parent share its observer, counters and epoch because they inherit the same sink. Their failed or blocked callbacks therefore appear in the parent's snapshot. Caller-built SubAgent(agent=...) children retain their own observer and sink; inventory and snapshot those separately when including them in deployment evidence. Sharing a sink across independently built agents does not merge their observers automatically.

At each paused/drained boundary, read dispatch_before = agent.dispatch_outcome_capture_counts and later dispatch_after = agent.dispatch_outcome_capture_counts. Require both readings, zero in-flight counts, an unchanged epoch and nondecreasing failure and delivered counts. Include each observer's actual journal files (and rotations) in expected_dispatch_journals, mapped by replica and journal ID. In both boundary records, include matching dispatch_snapshots entries containing failure_count, in_flight, epoch and delivered_count from those measured tuples. The delivered delta for each observer must equal the in-window dispatch rows in its declared journal files. An empty global dispatch journal, a failure delta, or any count mismatch invalidates healthy capture.

This accessor supplies dispatch capture health only. Staging deployment, complete replica/file inventory, host ingress/drain controls and invocation population reconciliation remain separate evidence obligations; these snapshots alone do not establish fleet zero, fallback unreachability or engine retirement.

Pre-admission Kernel-route attempts (CORE-300)

Install a separate host-owned callback with AgentBuilder.kernel_route_attempts(RouteAttemptJournal(path, release_version=..., deployment_id=...)) from symfonic.platform.route_attempt_journal. This observes the actual SymfonicAgent invocation route selection, before admission, release-contract validation or continuation token recognition. It does not count status reads of route_for, the typed stream's continuation-availability check, unconsumed generators, direct legacy routes, or guards that refuse before route selection. Resume checkpoint setup currently precedes selection; failures there do not become Kernel-route attempts. Preserve that distinction in ingress accounting.

kernel_route_attempt.v1 stores only entrypoint, capability, selected_route="kernel", observed_at, release_version and deployment_id along with its schema kind. It carries no payload, raw reason, run/session, tenant or token values. Later fallback or preparation failure leaves the attempt intact. Dispatch outcomes still describe the separate subsequent boundary; no native dispatch or native entry count is substituted for an attempt.

agent.kernel_route_attempt_capture_counts is an atomic (failure_count, in_flight, epoch, delivered_count) tuple, or None when unwired. Ordinary sink failures increment failures without changing admission or turn results; cancellation/BaseException is counted and propagates. Declarative children share their parent's observer and epoch. Independently supplied children must be inventoried separately, even when sharing a callback.

At both paused/drained boundaries, record these measured tuples under capture_boundaries[replica][boundary]["route_attempt_snapshots"][journal_id] using the same four named fields as dispatch snapshots. Declare every active observer's files and rotations in expected_route_attempt_journals, shaped as {replica: {journal_id: {"files": [path, ...]}}}. Independent observers cannot share an attested attempt file. The delivered delta must equal selected in-window attempt rows across those files. Missing inventory, snapshots, files or rotations, failures, in-flight callbacks, epoch replacement, counter resets and row mismatches prevent complete zero evidence. An absent sink is unknown, never an invented zero. The report exposes kernel_route_attempt_count, kernel_route_attempt_groups and route_attempt_capture_status separately.

To require the actual denominator, supply an explicit nonempty min_kernel_route_attempts_by_capability mapping in ZeroWindowPolicy or its CLI JSON policy, for example {"invocation.run": 100, "invocation.stream": 100}. These illustrative counts do not approve a policy. No threshold is inferred from observations; a supplied approval reference remains an unverified caller claim. Required capability groups below their positive minima return below_threshold; healthy measured attempt capture is mandatory. A continuation requirement additionally needs positive attempts through both resume and resume_interrupt; exercising one cannot certify the other. Native entry and dispatch capture requirements remain in force, and positive fallback/direct legacy observations retain precedence over incomplete evidence.

Policies omitting this mapping preserve their existing native-entry window semantics for old inputs and do not satisfy the CORE-24 per-capability attempt criterion. Selecting new attempt rows without their inventory makes the report incomplete for every policy. A file may intentionally contain several observation schemas: each observer is reconciled only against its own schema's rows; the attempt observer still requires its separate snapshot. Attempt policies report population_basis="pre_admission_kernel_route_attempts" and explicit capability thresholds/entrypoint counts. These producer observations cover the instrumented compatibility facade only; they do not automatically include direct public Agent roots or establish the whole ingress population. Complete fleet/host inventory, owner/SRE policy and an operated healthy window remain external evidence obligations. No bounded window verdict authorizes fallback unreachability or retirement.

Opt-in native entry capture

Install the journal through the existing host-owned event sinks, preserving other observers. The provider and scope are the deployment's own composition inputs; provision the journal directory before serving traffic.

from dataclasses import replace
from symfonic import Agent
from symfonic.platform import observability, telemetry
from symfonic.platform.native_entry_journal import NativeEntryJournal

services = observability()
native = NativeEntryJournal(
    "/var/lib/myapp/native.jsonl",
    release_version="11.0",
    deployment_id="staging-build-123",
    entrypoint="http_chat",
)
services = replace(services, event_sinks=(*services.event_sinks, native))
agent = Agent(provider, capabilities=[telemetry(services, scope)])

The binding hook records entry into the Kernel before its prompt/model stages, not a pre-Kernel admission attempt or successful completion. Provider construction can fail before that hook and produce no record. No run, tenant, session, or payload values are persisted; identity_known states only whether the supplied run identity was nonempty. Collector construction or an earlier sink's binding failure can prevent this journal's bind_run from being called; those missing entries do not increment its failure counter. Install this sink after any composition that replaces event_sinks (including generated OTel setup), and verify health independently; an uninstrumented root is not a zero-fallback root.

native.capture_snapshot atomically returns (failed_attempts, in_flight_attempts, capture_epoch). Failed writes are counted only after this journal's bind_run starts, even if the Kernel observer suppresses the exception and the public Agent.run result succeeds. Zero counted failures therefore does not establish complete native entry capture. A new journal instance gets a new epoch; do not replace or restart it during an attested window. A successful write after a failure does not repair the lost entry.

Keep NativeEntryJournal last in event_sinks: a disk failure in its binding hook skips run binding for later sinks in the existing fanout. Earlier sinks are already bound when that hook runs. This ordering concerns run binding, not event delivery, and does not change telemetry failure handling.

Read-only deployment report

Approved quick-nap composition refusal capture

The first approved native composition producer is opt-in and fixed to the shipped ConsolidationSchedule constructor, preserving nightly/deep defaults:

from symfonic.platform.migration_refusal_journal import MigrationRefusalJournal
from symfonic.platform.quick_nap_composition import quick_nap_schedule

refusals = MigrationRefusalJournal(
    "/var/lib/myapp/composition-refusals.jsonl",
    release_version="11.0",
    deployment_id="staging-build-123",
)
schedule = quick_nap_schedule(5, refusal_observer=refusals)
# quick_nap_schedule(0, refusal_observer=refusals) records the actual rejection
# and re-raises the same MemoryContractError. Disable naps by omitting their
# coordinator/stage, not by constructing a nonpositive schedule.

Only an exact built-in int <= 0, rejected by the shipped quick cadence validator, emits native_composition_refusal.v1. Boolean/custom/string inputs and unrelated errors are not classified. Traceback provenance narrows the producer; it makes no process-tamperproof claim. The frozen event records only quick_nap_nonpositive, ConsolidationSchedule.quick_every_turns, composition and approval reference CORE-1:d79e0ce1-8a48-4edc-bce4-966d9c40159b. The automation-authored approval record explicitly confirms the owner's decision on 2026-09-23; its author is not being identified as the owner.

No observer means no writes. Normal observer exceptions preserve the original refusal, while cancellation/BaseException propagates. The same local-lock, fsync, bounded-label, health, capacity and rotation limits apply to this journal. Include its files in the explicit report inventory. Refusals count composition attempts, not invocation population or Kernel entry; they cannot establish fallback rates, zero fallback, unreachability or retirement. CORE-59 remains incomplete: legacy invocation refusal approval is still UNKNOWN.

report_deployment_evidence reads only the explicit local JSONL paths supplied by an operator. It performs no discovery, network access, or writes. Select an aware UTC half-open window [start, end) and exact release/deployment labels:

from datetime import UTC, datetime
from symfonic.platform.deployment_evidence import report_deployment_evidence
from symfonic.platform.zero_window import evaluate_zero_window

# Single-replica example. At BOTH boundaries, deployment-specific host controls
# must pause ingress, drain every admitted turn (including pre-sink fallbacks),
# drain telemetry callbacks, then snapshot while ingress remains paused.
# The library does not perform or verify these controls. Set the attestations
# below to True only after those checks succeed; never replace None with zero.
capture_before = fallback_services.fallback_capture_snapshot
native_before = native.capture_snapshot
# dispatch_agent is the instrumented SymfonicAgent root from dispatch capture
# above; inventory caller-built children and independent observers separately.
dispatch_before = dispatch_agent.dispatch_outcome_capture_counts
capture_before_at = datetime.now(UTC)  # Local observation immediately after the read.
if (capture_before is None or dispatch_before is None or capture_before[1] != 0
        or native_before[1] != 0 or dispatch_before[1] != 0):
    raise RuntimeError("capture is unwired or not drained")
start = datetime.now(UTC)
# Resume ingress, serve the selected window, then pause/drain again.
end = datetime.now(UTC)
capture_after = fallback_services.fallback_capture_snapshot
native_after = native.capture_snapshot
dispatch_after = dispatch_agent.dispatch_outcome_capture_counts
capture_after_at = datetime.now(UTC)  # Local observation immediately after the read.
if (capture_after is None or dispatch_after is None
        or capture_after[2] != capture_before[2] or capture_after[1] != 0
        or native_after[2] != native_before[2] or native_after[1] != 0
        or dispatch_after[2] != dispatch_before[2] or dispatch_after[1] != 0):
    raise RuntimeError("capture sink restarted, replaced, or is not drained")
if (capture_after[0] < capture_before[0] or native_after[0] < native_before[0]
        or dispatch_after[0] < dispatch_before[0]
        or dispatch_after[3] < dispatch_before[3]):
    raise RuntimeError("capture counter reset")
paths = ["/var/lib/myapp/fallbacks.jsonl", "/var/lib/myapp/native.jsonl",
         "/var/lib/myapp/dispatch.jsonl"]
report = report_deployment_evidence(
    paths,
    release_version="11.0",
    deployment_id="staging-build-123",
    start=start,
    end=end,
    manifest={
        "release_version": "11.0",
        "deployment_id": "staging-build-123",
        "start": start,
        "end": end,
        "expected_files": paths,
        "expected_replicas": ["replica-a"],
        "expected_native_journals": {"replica-a": {"native-primary": {
            "files": ["/var/lib/myapp/native.jsonl"], "entrypoint": "http_chat",
        }}},
        "exercised_population": 100,
        # Required for a zero verdict: the fallback attribution journal is the
        # only one that records a legacy guard refusal as a fallback.
        "expected_fallback_journals": {"replica-a": {"fallback-primary": {
            "files": ["/var/lib/myapp/fallbacks.jsonl"],
        }}},
        "expected_dispatch_journals": {"replica-a": {"dispatch-primary": {
            "files": ["/var/lib/myapp/dispatch.jsonl"],
        }}},
        "minimum_exercised_population": 100,
        # The symfonic-core version deployed for this window, from your release
        # record; a zero verdict needs every in-window row stamped with it.
        "package_version": deployed_package_version,
        "coverage_attested": True,
        "health_attested": True,
        "capture_attested": True,
        "capture_boundaries": {"replica-a": {
            "start": {
                "ingress_paused_at_snapshot": True,
                "admitted_turns_drained": True,
                "telemetry_callbacks_drained": True,
                "observed_at": capture_before_at.isoformat(),
                "snapshot": {"failure_count": capture_before[0],
                             "in_flight": capture_before[1], "epoch": capture_before[2]},
                "native_snapshots": {"native-primary": {
                    "failure_count": native_before[0],
                    "in_flight": native_before[1], "epoch": native_before[2],
                }},
                "dispatch_snapshots": {"dispatch-primary": {
                    "failure_count": dispatch_before[0],
                    "in_flight": dispatch_before[1], "epoch": dispatch_before[2],
                    "delivered_count": dispatch_before[3],
                }},
            },
            "end": {
                "ingress_paused_at_snapshot": True,
                "admitted_turns_drained": True,
                "telemetry_callbacks_drained": True,
                "observed_at": capture_after_at.isoformat(),
                "snapshot": {"failure_count": capture_after[0],
                             "in_flight": capture_after[1], "epoch": capture_after[2]},
                "native_snapshots": {"native-primary": {
                    "failure_count": native_after[0],
                    "in_flight": native_after[1], "epoch": native_after[2],
                }},
                "dispatch_snapshots": {"dispatch-primary": {
                    "failure_count": dispatch_after[0],
                    "in_flight": dispatch_after[1], "epoch": dispatch_after[2],
                    "delivered_count": dispatch_after[3],
                }},
            },
        }},
    },
)
# Supply approved_policy as a ZeroWindowPolicy backed by an owner/SRE decision;
# this example does not select or approve deployment thresholds.
verdict = evaluate_zero_window(report, approved_policy)
# Read verdict["verdict"] for the bounded window decision.

The manifest is an operator attestation. Supply the complete replica and file inventory, including rotations; certify coverage, capture and journal health for that exact window. exercised_population and its minimum are explicit operator values; the report has no default population threshold or window and does not derive population from journal entries. The report derives capture_failure_count and native_capture_failure_count from each replica's start/end atomic snapshots. Declare every active NativeEntryJournal instance in expected_native_journals, keyed by replica and stable non-sensitive journal ID, with its file path(s) and entrypoint. Each native file must belong to one journal ID, appear in expected_files, and any native rows in it must use its declared entrypoint. Each boundary's native_snapshots must contain exactly those IDs. The report sums their failure deltas and refuses missing or extra snapshots, changed epochs, resets, and in-flight writes. Legacy native_snapshot manifests without this inventory remain readable but are incomplete and cannot receive a zero-window verdict. The inventory is an operator attestation: the report cannot discover an omitted journal or bind a row to a specific instance when journals share a file, so shared native files are refused. Certify every active instance and keep its epoch stable for the whole window; a replacement invalidates that window. Both boundaries require snapshots. Every listed replica must attest paused ingress, drained admitted turns and drained telemetry callbacks at both boundaries. Missing or false attestations, missing replicas, nonzero in-flight counts, a changed fallback or native capture epoch, a reset counter or a positive failure delta in either capture make completeness incomplete and withhold a zero-window verdict. Each boundary record must include an aware UTC observed_at: start must be at or before the requested start, end must be at or after the requested end, and start must precede end. Missing, invalid or out-of-order times make evidence incomplete. The snapshots bracket the selected window, so boundary-adjacent failures may conservatively increase the delta. The example records observation times immediately after snapshot reads, not window bounds; these remain operator claims. The report cannot verify that the deployment actually paused/drained, that the snapshots were taken at the claimed time, or that fleet membership is complete. A matching manifest produces at most completeness="operator_attested", never executable coverage proof.

Dispatch capture health is an additional zero-window requirement. Without expected_dispatch_journals, a readable report can still show completeness="operator_attested" and issues=[] while dispatch_capture_status="incomplete". It cannot reach zero_observed_within_policy; absent a higher-precedence observation or empty native population, its verdict is incomplete_evidence. Positive fallback or legacy-body observations and not_measurable take precedence. Supply the dispatch inventory and matching snapshots at both boundaries for every replica and observer. Healthy capture requires zero in-flight writes, unchanged epochs, nondecreasing counters and zero failure deltas. Use the evaluator's verdict field for the decision, rather than completeness alone.

The result separates native_entry_count (records of kernel_native_entry.v1, outcome="native_entry") from fallback_event_count (records of kernel_fallback_attribution.v1, outcome="legacy"). Fallback groups preserve entrypoint, capability and reason. Native entries group by the operator entrypoint label and expose the count with unknown identity. Neither count is a unique turn count, and the report computes no fallback rate. Native entry capture proves entry only, not successful completion or parity.

Fallback records retain their existing created_at UTC timestamp spelling; the reader also accepts observed_at. Native records use observed_at and a boolean identity_known, without copying identities. Malformed JSON, missing required fields, unknown schemas/outcomes, invalid timestamps, unreadable files and partial final lines are flagged, with valid positive observations retained. Valid records for other exact deployments/releases or outside the window are excluded. A record timestamp must be aware UTC; other offsets and naive times are rejected rather than silently reinterpreted. Duplicate paths, symlink/parent-path aliases, and hardlinks to the same opened capture file are rejected to prevent counting a capture twice.

Missing manifest population, replica/file inventory, capture/health/coverage attestation, unknown native identity, or a fallback with an unknown entrypoint, capability or reason code makes completeness incomplete. The report retains these fallback observations and exposes unknown_fallback_attribution_count so missing attribution cannot look like a complete capture. An empty selected population is status="not_measurable" even when all attestations are present. Positive counts return positive_observations, with any completeness limitations alongside them. Zero observed fallback events never mean zero proven fallback. approved_migration_refusal_count and its closed metadata groups report this composition category separately; its status is observed only for positive counts and otherwise unobserved. Refusal-only files retain positive refusal counts, but do not supply invocation population: the overall status remains not_measurable, completeness remains incomplete, and empty_observed_population remains present even with a complete manifest. The report cannot return a retirement pass, establish fallback unreachability, or discharge the fourteen-condition retirement gate.

Installed package version

Every JSONL journal described here (fallback attribution, dispatch outcome, Kernel route attempt, native entry and migration refusal) stamps package_version only when the symfonic-core dist-info that importlib.metadata resolves provably owns the code the process imported (symfonic.platform.artifact_identity). All of these must hold:

  • the install is not editable (direct_url.json);
  • its RECORD lists every loaded symfonic module, including symfonic/__init__.py and the stamping modules, at the path it was imported from, with a matching sha256. A loaded module the RECORD does not list (one added to the install later, or another distribution's) withholds the version;
  • its RECORD lists METADATA with a matching sha256, and the stamped version is read from those same bytes, so an edited version label is not stamped;
  • none of those files, nor the RECORD, changed after the process started (read from /proc);
  • the process itself imported symfonic: a worker forked from a parent that had already imported it, and had not stamped yet, runs code read before the worker's own start, so it is unstamped.

Otherwise the key is omitted, never invented and never read from pyproject.toml. So a source checkout, an editable install, a checkout shadowing another install's dist-info, a hand-edited file or version label, a host whose start time cannot be read (non-Linux) and a pre-fork server that imports Core in the master before its first journal write all write unstamped rows, which withhold zero. The result is resolved at the first journal write and frozen for the process. Each later write re-checks every module imported, re-imported or reloaded since, against the same RECORD bytes; a module that fails, or a RECORD that changed or is gone (a version-changing reinstall removes the old dist-info), withdraws the version for the rest of the process. Code loaded before the first write was attested by it, and a worker forked after that write keeps the parent's answer under the same re-checks. Only loaded modules are attested; a replaced file the process never imports is not. A file written in roughly the last 20 ms before the process started also counts as changed (fail closed). This proves the RECORD and the .py files on disk agree. It is not build authenticity: a RECORD rewritten to match edited files before the process starts passes, and the bytecode actually executed is not hashed.

The report adds package_version_counts (in-window rows per stamped version), unstamped_package_version_count (rows written before stamping, or by a process whose install could not be proven) and declared_package_version. Two stamped versions in one window add mixed_package_versions. A manifest may declare package_version; every in-window row, unstamped ones included, must then match it or the report adds package_version_mismatch. Each of these issues leaves the report incomplete, so the window verdict cannot read zero_observed_within_policy. A manifest that omits package_version reports unstamped rows without treating them as an issue, so the report stays useful for inspection. Rows written before stamping remain valid v1 rows.

A zero verdict additionally requires the version to be declared and bound: see package_version_binding under the window verdict below.

Zero-fallback window verdict

evaluate_zero_window(report, ZeroWindowPolicy(min_window=..., min_native_entries=..., approval_reference=...)) classifies one report as fallback_observed, legacy_body_observed, not_measurable, incomplete_evidence, below_threshold or zero_observed_within_policy. The policy has no defaults: no owner/SRE has approved a window or volume. Its required approval reference is supplied by the caller and echoed for audit; this function cannot verify the referenced decision. The proposed 28 days and 10,000 per-capability route attempts in docs/concepts/kernel-fallback-telemetry-v11.md are not interchangeable with this function's deployment-level native Kernel-entry count. Native entries carry no capability, so a per-capability denominator does not exist here. The span is the requested, operator-attested capture window, not evidence that events were evenly spread across it. zero_observed_within_policy means only that no fallback record was seen for that deployment/release window and no dispatch record observed a fallback or direct legacy body, with attested capture, healthy dispatch capture and healthy fallback attribution journal capture for every listed replica, and that every in-window row was stamped with the one package_version the manifest declares. A dispatch fallback or direct legacy outcome returns legacy_body_observed when no fallback journal record is present. This does not satisfy the per-capability retirement criterion.

Every verdict carries package_version_binding. Only package_version_bound permits zero: the manifest declared a bounded package_version, every in-window row is stamped, and every stamp equals the declaration. Otherwise the value names what is missing and the verdict is incomplete_evidence:

package_version_binding Meaning What to do
package_version_undeclared The manifest omits package_version, or declares an invalid value such as unknown Declare the deployed version from your release record
package_version_unstamped_rows Some in-window rows carry no version (pre-stamping install, or unreadable metadata) Capture a window written entirely by a stamping install
package_version_mixed Rows from two installed versions share the window Narrow the window to one rollout, or report each version separately
package_version_mismatch Rows are from one version, but not the declared one Correct the declaration or the deployment you selected
package_version_unreported / package_version_unobserved The report carries no usable version counts Pass an unmodified report_deployment_evidence result

Zero also requires the fallback attribution journal. The dispatch journal does not see every legacy body reach: with ask_user_enabled and no scope or session, the turn falls back and the guard that refuses it is the first statement of the legacy body. Dispatch records that turn as approved_rejection with selected_route="none"; only the fallback journal records it as a legacy fallback. So the manifest must declare expected_fallback_journals (per replica, {journal_id: {"files": [...]}}), and every declared file must be selected and readable. Create the file before the window opens, because a journal with no fallback has never been written. The report's fallback_capture_status is:

  • undeclared: no inventory. This is not a report issue; old manifests stay readable.
  • incomplete: a malformed inventory, a declared file not selected or unreadable, a fallback row in an undeclared file (fallback_journal_binding_mismatch), a declared file that is also a native, dispatch or route-attempt journal or holds any other row kind (fallback_journal_kind_mismatch), one file declared twice, for two replicas or through a symlink (fallback_journal_file_overlap), or a failed or in-flight fallback capture. Give every replica's FallbackJournal its own file; fallback rows in a mismatched file are still counted.
  • healthy: zero is permitted.

incomplete_evidence also lists incomplete_reasons: any of report_incomplete (see the report's issues), dispatch_capture_unhealthy, route_attempt_capture_unhealthy, fallback_capture_undeclared, fallback_capture_incomplete, and the binding value above. Neither requirement hides a finding: an observed fallback or legacy body is reported whatever the version or capture evidence says.