Skip to content

Command handlers: an applied CFIX issued with a DVIA loses its altitude reverts when the paired DVIA is dropped — aircraft never climbs back (NavigationCommandHandler, YAAT-956) #1339

Closed

Description

@leftos

Summary

An already-applied CFIX that was issued in the same transmission as a DESCEND VIA (DVIA) permanently loses its altitude reverts when that paired DVIA is later dropped (or refused) before it fires. The aircraft then crosses the fix at the restriction and never climbs back to the altitude it was assigned before the CFIX — it is stranded low with no valid clearance.

This is a gap in yesterday's YAAT-956 change (commit 68c44641, "fix: let DVIA clear the assigned altitude and supersede old crossings"). The queued-CFIX counterpart of this scenario is already handled and pinned by a test; the already-applied-CFIX path is not.

Confidence: medium-high (repro below fails deterministically against current main; narrow but real trigger).

Root cause

When a transmission carries both a CFIX and a DVIA, DispatchCrossFix eagerly nulls the fix's reverts at CFIX-apply time, betting the DVIA will take over the descent profile:

src/Yaat.Sim/Commands/NavigationCommandHandler.cs:222

double? previousAlt = transmissionDescendsVia ? null : aircraft.Targets.TargetAltitude;
double? previousAssignedAlt = transmissionDescendsVia ? null : aircraft.Targets.AssignedAltitude;
double? previousSpeed = ((cmd.Speed is not null) && !transmissionDescendsVia) ? aircraft.Targets.TargetSpeed : null;
double? previousAssignedSpeed = ((cmd.Speed is not null) && !transmissionDescendsVia) ? aircraft.Targets.AssignedSpeed : null;

If the DVIA is subsequently dropped out of the queue (a lateral vector, DELAT/DELCOND, or a fire-time chain abort), the repair only reaches blocks that are still queued:

src/Yaat.Sim/CommandQueue.cs:446 — ForgetDroppedDescendVia clears TransmissionDescendsVia on the remaining blocks only, so a queued CFIX re-fires as a lone CFIX with its reverts. An already-applied CFIX has already stamped the fix with null reverts and is never touched — and the original pre-CFIX assigned altitude was never stored, so it cannot be recovered after the fact.

The DVIA's own supersede pass (SupersedeEarlierCrossingRestrictions, NavigationCommandHandler.cs:1361, nulls the four reverts at lines 1386-1389) only runs when the DVIA actually fires — which, in this scenario, it never does.

The queued-CFIX path is covered by QueuedSameTransmissionCrossFix_DescendViaBlockDropped_KeepsTheReverts; the applied-CFIX path has no coverage and is where the bug lives.

Reproduction (fails on current main)

Add to tests/Yaat.Sim.Tests/Simulation/DescendViaSupersedesEarlierAltitudeTests.cs (uses the file's existing BuildOakesHarness / real KOAK OAKES3 navdata, no synthetic data):

/// <summary>
/// "CFIX X 110; AT A DVIA" applies the CFIX immediately (block 0, unconditional) while the DVIA waits behind its
/// trigger. A later command that drops the queued DVIA block must leave the already-applied CFIX behaving as a lone
/// CFIX — i.e. keeping its reverts — exactly as the queued-CFIX counterpart does.
/// </summary>
[Fact]
public void AppliedCrossFix_ThenQueuedDescendViaDropped_KeepsTheReverts()
{
    if (BuildOakesHarness() is not { } harness)
    {
        _output.WriteLine("KOAK ground layout is unavailable, skipping");
        return;
    }

    AircraftState aircraft = harness.Aircraft;
    string triggerFix = aircraft.Targets.NavigationRoute[1].Name;
    string fixName = aircraft.Targets.NavigationRoute[3].Name;
    SendOk(harness, "DM 150");

    // CFIX is unconditional -> block 0 applies now; "AT <trigger> DVIA" waits behind its trigger. The crossing fix
    // lies past the trigger, so it stays on the route.
    SendOk(harness, $"CFIX {fixName} 110; AT {triggerFix} DVIA");

    NavigationTarget fix = aircraft.Targets.NavigationRoute.First(t => t.Name == fixName);
    Assert.Equal(new CifpAltitudeRestriction(CifpAltitudeRestrictionType.At, 11000), fix.AltitudeRestriction);
    Assert.Contains(aircraft.Queue.Blocks, CarriesDescendVia);

    // Drop the queued DVIA block with DELAT; it removes the conditional without rerouting, so the applied crossing
    // stays on the route. DELAT 2 targets the DVIA (#1 is the still-tracking CFIX, #2 is "at GRUDG: DVIA").
    SendOk(harness, "DELAT 2");
    Assert.DoesNotContain(aircraft.Queue.Blocks, CarriesDescendVia);
    Assert.False(aircraft.Procedure.StarViaMode);
    Assert.Contains(aircraft.Targets.NavigationRoute, t => t.Name == fixName);

    // The DVIA never stood, so the applied CFIX must revert to the 15,000 it was assigned before — just as the
    // queued-CFIX case (QueuedSameTransmissionCrossFix_DescendViaBlockDropped_KeepsTheReverts) does.
    Assert.Equal(15000, fix.RevertAssignedAltitude);
}

Observed failure (real spawn, route OAKES GRUDG BANND TOOOL FFIST):

DM 150 -> Descend and maintain 15000
CFIX TOOOL 110; AT GRUDG DVIA -> Cross TOOOL at 11,000 ; then At GRUDG: Descend via STAR
DELAT 2 -> Deleted [2] At GRUDG: Descend via STAR
Assert.Equal() Failure: Values differ
Expected: 15000
Actual:   null     // fix.RevertAssignedAltitude — the reverts were eagerly dropped and never restored

The same loss occurs when the DVIA is refused at fire time (e.g. CFIX X 110; DVIA on an aircraft with no active/filed STAR): the CFIX commits null reverts, the chain abort discards the DVIA block, and the fix is left with a crossing and no revert.

Suggested fix

The eager null at NavigationCommandHandler.cs:222-225 is correct only when the DVIA has already taken effect for this transmission (the "DVIA then CFIX" order, pinned by DescendVia_ThenSameTransmissionCrossFix_KeepsTheRestriction_NoRevert). When the DVIA is still pending, the CFIX should capture its real reverts and let SupersedeEarlierCrossingRestrictions null them if/when the DVIA fires; if the DVIA is instead dropped, the real reverts then correctly survive.

Note this is not a one-line mechanical change: keying purely on the global aircraft.Procedure.StarViaMode is insufficient (StarViaMode may already be true from an earlier, unrelated DVIA while this transmission's DVIA is still pending). A correct fix needs to know whether this transmission's DVIA has applied — e.g. stamp the pending DVIA's transmission id and only eager-null once that specific DVIA has fired, or store the suppressed reverts and have ForgetDroppedDescendVia promote them back onto already-applied fixes of the dropped transmission. Because this is descend-via / altitude-management logic, it should go through the mandatory aviation-sim-expert review and the full DescendViaSupersedesEarlierAltitudeTests suite.


Filed by the YAAT nightly reviewer. Subsystem rotation: docs/flight-strips.md (no new strips findings); recent-commit review surfaced this in 68c44641 (YAAT-956).

Orchestrator decision, 2026-10-22 (not an owner ruling; open to the owner): Design (a): AircraftProcedure.StarViaTransmissionId / ClimbViaTransmissionId record which transmission's DVIA / CVIA actually fired. A CFIX skips its reverts only when its own transmission's via has already fired; otherwise it captures them and SupersedeEarlierCrossingRestrictions nulls them when the via fires. Covers DVIA and CVIA. The stamped ids are cleared with the via mode and on transmission-id rewind. The block flags and ForgetDroppedViaClearances go if nothing else reads them. No snapshot migration (new fields default to 0).

Settled (owner ruling, 2026-10-22): Land YAAT-984 as is; YAAT-1023 (restore an altitude only when it continues the direction of flight, else keep the crossing altitude) joins vNext and is fixed before the release, so no release ships a climb-back.

Activity

  1. added
    nightly-reviewFiled by the automated nightly code review
    automatedOpened by an automated process
    on Oct 21, 2026
  2. linear-code commented on Oct 21, 2026

    @linear-code
  3. leftos commented on Oct 22, 2026

    @leftos
    OwnerAuthor

    🤖 Posted by Claude Code on behalf of @leftos.

    This is fixed on the default branch in 2c4eae3 and ships in the next release.

  4. self-assigned this
    on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

automatedOpened by an automated processbugSomething isn't workingnightly-reviewFiled by the automated nightly code review

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions