Skip to content
21 changes: 20 additions & 1 deletion src/background/filed-returns-full-fiscal-year-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,25 @@ export function markFullFiscalYearTargetTerminal(
};
}

/**
* The terminal phase for a cleanup, keeping which pending phase it came from.
*
* Three routes reach cleanup and only one of them delivered a ZIP to the
* browser. Writing a single `cleaned` for all three discarded the distinction
* at exactly the moment it became the only record of it.
*
* An unrecognised or absent pending phase stays on the origin-less `cleaned`
* rather than guessing a route, so it reads as indeterminate.
*/
function cleanedPhaseFor(
pending: FiledReturnsFullFiscalYearLedger["zipPhase"],
): NonNullable<FiledReturnsFullFiscalYearLedger["zipPhase"]> {
if (pending === "downloaded-cleanup-pending") return "cleaned-after-download";
if (pending === "no-artifacts-cleanup-pending") return "cleaned-without-export";
if (pending === "legacy-cleanup-pending") return "cleaned-legacy";
return "cleaned";
}

export function completeFullFiscalYearLedger(
ledger: FiledReturnsFullFiscalYearLedger,
now: Date,
Expand All @@ -320,7 +339,7 @@ export function completeFullFiscalYearLedger(
revision: nextRevision(ledger),
status: "complete",
updatedAt: now.toISOString(),
zipPhase: "cleaned",
zipPhase: cleanedPhaseFor(ledger.zipPhase),
};
delete completedLedger.zipDownloadAttempt;
return completedLedger;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
FiledReturnsFullFiscalYearLedger,
PortalFlowStepResult,
} from "../connectors/gst/filed-returns-contracts";
import { isCleanedZipPhase } from "../connectors/gst/filed-returns-contracts";
import type { PackMessageResponse } from "../connectors/gst/messages";
import { filedReturnScopeId } from "../connectors/gst/filed-returns-return-descriptors";
import type { FiledReturnsFlowRunnerDeps } from "./filed-returns-flow-runner";
Expand Down Expand Up @@ -39,7 +40,7 @@ export function hasDownloadUnconfirmedTarget(ledger: FiledReturnsFullFiscalYearL
export function hasRetainedFullFiscalYearStaging(
ledger: FiledReturnsFullFiscalYearLedger,
): boolean {
if (ledger.zipPhase === "cleaned") return false;
if (isCleanedZipPhase(ledger.zipPhase)) return false;
if (ledger.zipPhase !== undefined) return true;
if (hasLegacyRetainedStaging(ledger)) return true;
return ledger.targets.some((target) =>
Expand Down
27 changes: 24 additions & 3 deletions src/background/filed-returns-full-fiscal-year-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,34 @@ export function createFullFiscalYearCleanupPendingState(
};
}

/**
* The cleanup route to retry for a ledger that has already been cleaned once.
*
* Never upgrades. Re-cleaning a completed ledger must end on the terminal phase
* it already had, because that phase is the delivery evidence -- and resetting
* it to the delivery route relabelled a run that never exported anything as one
* whose ZIP the browser confirmed.
*
* A pre-split `cleaned` carries no origin, so it takes the non-delivery route
* and stays reading as captured, which is what it did before.
*/
export function cleanupPendingPhaseFor(
cleaned: FiledReturnsFullFiscalYearLedger["zipPhase"],
): "downloaded-cleanup-pending" | "no-artifacts-cleanup-pending" | "legacy-cleanup-pending" {
if (cleaned === "cleaned-after-download") return "downloaded-cleanup-pending";
if (cleaned === "cleaned-without-export") return "no-artifacts-cleanup-pending";
return "legacy-cleanup-pending";
}

export function markFullFiscalYearCleanupPending(
ledger: FiledReturnsFullFiscalYearLedger,
now: Date,
// No default. `downloaded-cleanup-pending` was the default value, and it is
// the one phase that asserts the ZIP reached the browser -- so a caller that
// simply did not think about the argument claimed a delivery. Every call site
// now states which route it is on.
zipPhase:
| "downloaded-cleanup-pending"
| "no-artifacts-cleanup-pending"
| "legacy-cleanup-pending" = "downloaded-cleanup-pending",
"downloaded-cleanup-pending" | "no-artifacts-cleanup-pending" | "legacy-cleanup-pending",
): FiledReturnsFullFiscalYearLedger {
const requestedAt = ledger.zipDownloadAttempt?.requestedAt;
const cleanupPendingLedger: FiledReturnsFullFiscalYearLedger = {
Expand Down
112 changes: 91 additions & 21 deletions src/background/filed-returns-full-fiscal-year-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type {
FiledReturnsFullFiscalYearTargetStatus,
PortalFlowStepResult,
} from "../connectors/gst/filed-returns-contracts";
import {
isCleanedZipPhase,
zipPhaseProvesDelivery,
} from "../connectors/gst/filed-returns-contracts";
import {
filedReturnsArtifactLabel,
normaliseFiledReturnsArtifactType,
Expand All @@ -22,7 +26,7 @@ import {
export function fullFiscalYearZipPhaseStep(
ledger: FiledReturnsFullFiscalYearLedger,
): PortalFlowStepResult | null {
if (ledger.zipPhase === "cleaned") return null;
if (isCleanedZipPhase(ledger.zipPhase)) return null;
const legacyRetained = hasLegacyRetainedStaging(ledger);
if (ledger.zipPhase === undefined && !legacyRetained) return null;
if (ledger.zipPhase === "restaging-required") {
Expand Down Expand Up @@ -230,38 +234,50 @@ function targetOutcome(
status: FiledReturnsFullFiscalYearTargetStatus,
zipDelivered: boolean,
runInterrupted: boolean,
missedAnArtifact: boolean,
): FiledReturnsTargetOutcome {
const outcome = TARGET_OUTCOMES[status];
// `summariseFullFiscalYearLedger` reports an interrupted run as blocked while
// leaving the current target's durable status at `running`. Nothing is
// running after an MV3 worker interruption, and reading it as "In progress"
// both misdescribes it and excludes it from the count of what needs a person.
if (outcome === "running" && runInterrupted) return "needs-review";
return outcome === "saved" && !zipDelivered ? "captured" : outcome;
if (outcome !== "saved") return outcome;
// Delivery first. Whether the browser has the bytes is a stronger question
// than how much of the selection they represent, and a partial claim about a
// file Pack has not handed over would assert the handover in passing.
if (!zipDelivered) return "captured";
return missedAnArtifact ? "partly-saved" : "saved";
}

/**
* Whether the ZIP reached the browser.
*
* The signal alone, deliberately. `zipPhase: "cleaned"` looked like the durable
* form of the same fact and is not: three different phases reach it -- a
* confirmed download, a run that found no artifacts, and a legacy retained
* staging cleared on upgrade. Only the first is a delivery, and the phase that
* distinguishes them is overwritten by the transition, so the origin cannot be
* recovered from the ledger afterwards.
* The transient signal, or the durable phase that records the same delivery.
*
* The signal is emitted by the step that observes the download and is absent
* from the step a later re-summarisation builds, so it alone made a delivered
* run read as merely captured once the panel was reopened.
*
* Inferring from it reported never-exported files as saved. That is the
* overclaim this list exists to prevent, so the inference is gone rather than
* narrowed -- a guard that fails closed states what it can prove and no more.
* The old `zipPhase: "cleaned"` could not stand in for it: three phases reached
* that one value -- a confirmed download, a run that produced no ZIP, and a
* legacy staging cleared on upgrade -- and an inference built on the collapsed
* value reported never-exported files as saved. Cleanup now keeps its origin,
* so `cleaned-after-download` is the delivery and its siblings are not. The
* fix is to stop discarding the fact, not to guess it back afterwards.
*
* The cost is real and is the right way round: a delivered run re-summarised
* after the panel reopens reads `captured` rather than `saved`, because at that
* point Pack genuinely cannot prove the browser still holds the file. Recording
* delivery durably would fix that, and is a persistence change to raise rather
* than make.
* A ledger written before the split carries the origin-less `cleaned` and stays
* indeterminate: those runs still read as captured, which is what they did
* before and is the safe direction for a claim about a file.
*/
function isFullFiscalYearZipDelivered(flowStep: PortalFlowStepResult): boolean {
return flowStep.safeSignals.includes("full-fiscal-year-zip-downloaded");
function isFullFiscalYearZipDelivered(
ledger: FiledReturnsFullFiscalYearLedger,
flowStep: PortalFlowStepResult,
): boolean {
return (
flowStep.safeSignals.includes("full-fiscal-year-zip-downloaded") ||
zipPhaseProvesDelivery(ledger.zipPhase)
Comment thread
lamemustafa marked this conversation as resolved.
);
}

/**
Expand All @@ -278,7 +294,7 @@ export function fullFiscalYearTargetEvidence(
ledger: FiledReturnsFullFiscalYearLedger,
flowStep: PortalFlowStepResult,
): FiledReturnsTargetEvidence[] {
const zipDelivered = isFullFiscalYearZipDelivered(flowStep);
const zipDelivered = isFullFiscalYearZipDelivered(ledger, flowStep);
// A discarded run: staged files were cleared without a delivery. Their targets
// still read `downloaded`, and reporting those as captured claims Pack holds
// files it has just deleted.
Expand Down Expand Up @@ -319,10 +335,45 @@ export function fullFiscalYearTargetEvidence(
RUN_INDETERMINATE_SIGNALS.some((signal) => flowStep.safeSignals.includes(signal));
return ledger.targets.map((target) => ({
period: target.period,
outcome: targetOutcome(target.status, zipDelivered, runInterrupted),
outcome: targetOutcome(
target.status,
zipDelivered,
runInterrupted,
targetMissedAnArtifact(target),
),
}));
}

/**
* Whether the portal gave this period only part of what was selected.
*
* Read from the target rather than from the run's step. The step carries the
* signal for whichever period is in flight, so testing it would mark every
* period of the year partial because one of them was -- and the ledger is the
* only place the fact is held per period.
*
* It is there because `filed-return-artifact-unavailable:<TYPE>` survives
* `parseDurableFiledReturnsSignals`, so a terminal target keeps it. No new
* persisted field was needed: the information was already stored, on the
* record that already scopes it correctly, and only the derivation was reading
* the wrong one.
*/
function targetMissedAnArtifact(target: FiledReturnsFullFiscalYearTarget): boolean {
// Dereferenced without a fallback, like `hasLegacyRetainedStaging` above it.
// A `?? []` here would read as caution and is the opposite: absent signals
// would mean no evidence of a gap, which resolves to the *stronger* claim of a
// fully saved period. That is "could not determine" answering "matches", on
// the one field that decides the difference.
//
// The field is required by the type, set by every construction path, and its
// absence fails ledger validation, so this cannot be reached with a real
// record. A malformed one throwing here is diagnosable; a malformed one
// silently reading as saved is not.
return target.safeSignals.some((signal) =>
signal.startsWith("filed-return-artifact-unavailable:"),
);
}

export function toFullFiscalYearSummary(
ledger: FiledReturnsFullFiscalYearLedger,
flowStep: PortalFlowStepResult,
Expand Down Expand Up @@ -358,14 +409,33 @@ export function toFullFiscalYearSummary(
};
}

/**
* The step a re-summarisation builds from the ledger alone.
*
* It carries the delivery signal when the ledger's cleanup phase records one.
* Five places ask "did the ZIP reach the browser" and every one of them asks it
* of this step's signals -- the panel banner, the pack summary line, two durable
* status derivations, and the per-period evidence. Teaching only the evidence to
* read the durable phase made it disagree with the other four: a live run
* reported twelve periods `Saved` beneath a banner saying Pack could not confirm
* the browser had the ZIP, which is one screen contradicting itself.
*
* So the fact is restored where it was lost, not patched into each reader. The
* signal is the existing canonical one and survives durable parsing, so a step
* built after a restart is indistinguishable from the one the observing run
* emitted -- which is the point.
*/
export function completeFullFiscalYearStep(
ledger: FiledReturnsFullFiscalYearLedger,
): PortalFlowStepResult {
return {
connectorId: "gst",
scopeId: filedReturnsScopeId(ledger.scope.returnType),
state: "downloaded",
safeSignals: ["full-fiscal-year-complete"],
safeSignals: [
"full-fiscal-year-complete",
...(zipPhaseProvesDelivery(ledger.zipPhase) ? ["full-fiscal-year-zip-downloaded"] : []),
],
safeMessage: `Pack completed the local full fiscal year run for FY ${ledger.scope.financialYear}.`,
};
}
Expand Down
9 changes: 5 additions & 4 deletions src/background/filed-returns-full-fiscal-year-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
FiledReturnsFullFiscalYearTarget,
FiledReturnsFullFiscalYearTargetStatus,
} from "../connectors/gst/filed-returns-contracts";
import { isCleanedZipPhase, CLEANED_ZIP_PHASES } from "../connectors/gst/filed-returns-contracts";
import {
isFiledReturnsArtifactType,
normaliseFiledReturnsArtifactType,
Expand Down Expand Up @@ -154,7 +155,7 @@ const VALID_ZIP_PHASES = new Set<NonNullable<FiledReturnsFullFiscalYearLedger["z
"downloaded-cleanup-pending",
"no-artifacts-cleanup-pending",
"legacy-cleanup-pending",
"cleaned",
...CLEANED_ZIP_PHASES,
]);
const ZIP_PHASES_REQUIRING_COMPLETED_TARGETS = new Set<
NonNullable<FiledReturnsFullFiscalYearLedger["zipPhase"]>
Expand All @@ -167,7 +168,7 @@ const ZIP_PHASES_REQUIRING_COMPLETED_TARGETS = new Set<
"downloaded-cleanup-pending",
"no-artifacts-cleanup-pending",
"legacy-cleanup-pending",
"cleaned",
...CLEANED_ZIP_PHASES,
]);
const COMPLETED_TARGET_STATUSES = new Set<FiledReturnsFullFiscalYearTargetStatus>([
"downloaded",
Expand Down Expand Up @@ -237,10 +238,10 @@ export function isFullFiscalYearLedger(input: unknown): input is FiledReturnsFul
return false;
}
if (!isValidZipDownloadAttempt(ledger)) return false;
if (ledger.zipPhase === "cleaned" && ledger.status !== "complete") return false;
if (isCleanedZipPhase(ledger.zipPhase) && ledger.status !== "complete") return false;
if (
ledger.zipPhase !== undefined &&
ledger.zipPhase !== "cleaned" &&
!isCleanedZipPhase(ledger.zipPhase) &&
ledger.status !== "blocked"
) {
return false;
Expand Down
11 changes: 10 additions & 1 deletion src/background/filed-returns-full-fiscal-year.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
createFullFiscalYearCleanupPendingState,
finishFullFiscalYearCleanup,
mergeRetriedArtifactSignals,
cleanupPendingPhaseFor,
markFullFiscalYearCleanupPending,
markFullFiscalYearRestagingRequired,
markFullFiscalYearZipDownloadIntent,
Expand Down Expand Up @@ -222,7 +223,15 @@ export async function startFullFiscalYearDownloadFlow(
if (existingLedger && replaceCompletedSameScopeLedger) {
const clearSignals = await discardFullFiscalYearFiledReturnsZip(existingLedger.ledgerId);
if (!clearSignals.includes("full-fiscal-year-opfs-cleared")) {
const cleanupPendingLedger = markFullFiscalYearCleanupPending(existingLedger, now);
// Derived from the phase this ledger already reached, never reset. This
// call took the default before, which is `downloaded-cleanup-pending` --
// so retrying the cleanup of a run that never exported a ZIP promoted it
// to the delivery route and, on completion, to `cleaned-after-download`.
const cleanupPendingLedger = markFullFiscalYearCleanupPending(
existingLedger,
now,
cleanupPendingPhaseFor(existingLedger.zipPhase),
);
const step = completedRunCleanupBlockedStep(cleanupPendingLedger, clearSignals);
const summary = toFullFiscalYearSummary(cleanupPendingLedger, step);
await persistLedgerAndSummary(deps, cleanupPendingLedger, step);
Expand Down
3 changes: 2 additions & 1 deletion src/background/local-data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { browser } from "wxt/browser";
import type { FiledReturnsFullFiscalYearLedger } from "../connectors/gst/filed-returns-contracts";
import { isCleanedZipPhase } from "../connectors/gst/filed-returns-contracts";
import type { PackMessageResponse } from "../connectors/gst/messages";
import {
readActiveFiledReturnsRunStorageState,
Expand Down Expand Up @@ -127,7 +128,7 @@ async function hasUnresolvedFiledReturnsRecoveryState(deps: PackLocalDataDeps):

function hasUnresolvedZipState(ledger: FiledReturnsFullFiscalYearLedger): boolean {
if (ledger.zipDownloadAttempt !== undefined) return true;
return ledger.zipPhase !== undefined && ledger.zipPhase !== "cleaned";
return ledger.zipPhase !== undefined && !isCleanedZipPhase(ledger.zipPhase);
}

function isUnresolvedFullFiscalYearLedger(ledger: FiledReturnsFullFiscalYearLedger): boolean {
Expand Down
Loading
Loading