Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/LIVE_FILED_RETURNS_SPIKE.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,11 +494,19 @@ safe-result inspection found one GSTR-1/IFF result row for May and no
`/returns/auth/gstr1`, with safe signals for the GSTR-1 detail route, GSTR-1
heading and filed status.

The live PDF path is different from GSTR-3B: the filed GSTR-1 detail page does
not expose the final summary PDF download directly. It exposes a portal
`View Summary` action whose live merged label appeared as
`VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action opens a
separate summary page with the PDF download control near the bottom.
> **Superseded in part on 2026-09-10.** The paragraph below records what this
> spike saw, and it is accurate for that capture. It is not the general rule it
> was read as: a later capture found the filed-PDF control on the detail route
> itself, with no `View Summary` control on the page at all. The detail route
> has **two** shapes, and a flow written from this paragraph alone waits out its
> step budget on the other one. See "Filed GSTR-1 detail route can be the
> download surface itself" in `PORTAL_INTEGRATION_FINDINGS.md`.

The live PDF path is different from GSTR-3B: on the page captured here, the
filed GSTR-1 detail page does not expose the final summary PDF download
directly. It exposes a portal `View Summary` action whose live merged label
appeared as `VIEW SUMMARY PROCEED TO FILE/SUMMARY VIEW SUMMARY`; that action
opens a separate summary page with the PDF download control near the bottom.

Implementation changes from this evidence:

Expand Down
33 changes: 33 additions & 0 deletions docs/PORTAL_INTEGRATION_FINDINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,36 @@ read-only live probe confirms a stable, scope-bound source for the necessary fac
cadences and registration states. Until then, the evidence supports neither replacing the calendar
threshold nor suppressing a reader-selected period; record a bounded, user-visible not-filed or
unresolved outcome when the acquisition flow establishes one.

## Filed GSTR-1 detail route can be the download surface itself (2026-09-10)

Captured live, authenticated, source-surfaces build v0.6.0, on the GSTR-1 detail route
(`/returns/auth/gstr1`). No value read from the page is reproduced here: this file records what the
portal's surfaces do, not what any return says.

**The page carried the filed-PDF control and no navigation step.** Its control row offered a back
action, an e-invoice details Excel download, a disabled reset, and a filed-PDF download labelled
`DOWNLOAD FILED (PDF)`. There was no `VIEW SUMMARY` control anywhere on it — confirmed against the
full page, header band through footer.

**What this falsifies.** `LIVE_FILED_RETURNS_SPIKE.md` recorded the opposite from an earlier
capture: that the detail page never exposes the PDF and must be navigated through `View Summary`.
That paragraph is accurate for the page it saw and wrong as a general rule. The detail route has two
shapes. `clickFiledGstr1SummaryForPdf` searched for a control that does not exist on this one and
re-emitted `filed-gstr1-summary-view-pending` every step until the flow step limit, so the run
reported `blocked` / `user-action-required` with no action a user could take. Observed signals:
`gstr-1-detail-route`, `filed-gstr1-summary-view-pending`, `flow-step-limit-reached`.

**Why both layers missed the control.** The label is `DOWNLOAD FILED (PDF)` — no return type after
`FILED`. `explicitDownloadPattern` for GSTR-1 required `download filed gstr1`, and
`secondaryDownloadPattern` required `download` immediately before `pdf`, so the intervening `filed`
defeated it. The observer carried its own inline copy of that second pattern rather than reading the
descriptor, so the same fact had to be wrong in two places at once.

**The fix keys off the control, not the shape.** Whether this variant belongs to a particular filing
cadence is unestablished — one page was captured — so the flow asks whether a filed-PDF control is
present and clickable rather than inferring it from the route or from page text. A label appearing
in page copy is not a control: decoy or non-actionable text carrying the same words would otherwise
suppress a real `View Summary` step and strand a target whose PDF was reachable.

**Not yet established.** Which periods render which shape, and whether a page can offer both.
43 changes: 43 additions & 0 deletions src/background/background-failure-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Enough to name a thrown failure, and nothing that could carry portal text.
//
// An error's message is not safe to render: it can quote a page, a URL, or a field value. Its
// class name and the innermost stack symbol can name where a failure happened without repeating
// anything the portal said -- but only if each is established to be this bundle's, rather than
// merely made to look harmless.
//
// Stripping punctuation was the earlier approach and it was worse than no filter: a frame pointing
// at a portal URL came back with its slashes deleted, so the value *looked* like a symbol
// precisely because the characters that would have exposed it were gone. A filter that launders
// its input is not a guard.
//
// This exists because a discarded error cost three separate round trips to locate, each needing a
// build and a live authenticated run.

// A frame names a place in this bundle only when the file it points at is this bundle's. The
// symbol is what is kept; the URL is what proves the symbol is ours, and it is never kept. An
// anonymous frame has no symbol to take, and a frame from anywhere else does not match at all.
const BUNDLE_FRAME = /^\s*at\s+(?:async\s+)?([A-Za-z_$][\w$.]{0,59})\s+\(chrome-extension:\/\//u;

// Every error class in this bundle ends in `Error`, as do the platform's own; `DOMException` is
// the one exception the platform makes. Letters and that suffix cannot spell a GSTIN, an ARN, or
// a URL, and a name that fails the shape degrades to `Error` rather than being laundered into one.
const BUNDLE_ERROR_NAME = /^[A-Za-z]{1,40}Error$/u;

function safeErrorName(name: string): string {
if (name === "DOMException") return name;
return BUNDLE_ERROR_NAME.test(name) ? name : "Error";
}

export function backgroundFailureFingerprint(error: unknown): string {
if (!(error instanceof Error)) return "NonError";
const name = safeErrorName(error.name || "Error");
const symbol =
typeof error.stack === "string"
? (error.stack
.split("\n")
.slice(1)
.map((line) => BUNDLE_FRAME.exec(line)?.[1])
.find(Boolean) ?? "")
: "";
return symbol ? `${name} at ${symbol}` : name;
}
141 changes: 113 additions & 28 deletions src/background/filed-returns-download-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ import {
gstr3bFullFiscalYearAcquisitionNotWiredStep,
isGstr3bFullFiscalYearAcquisitionScope,
} from "./gstr3b-artifact-acquisition-block";
import { DECLINED_ARTIFACT_SIGNALS } from "../connectors/gst/filed-returns-acquisition-diagnostics";

type FlowStepResponse = Extract<PackMessageResponse, { ok: true; flowStep: PortalFlowStepResult }>;

// Only the portal's own declined-artifact answers are adopted from a post-click inspection; any
// other page state leaves the original acquisition failure standing with its reason intact.
const DECLINED_ARTIFACT_SIGNAL_SET = new Set<string>(DECLINED_ARTIFACT_SIGNALS);

async function persistSingleArtifactRecoveryIntent(
scope: FiledReturnsDownloadScope,
artifactType: FiledReturnsConcreteArtifactType,
Expand Down Expand Up @@ -672,41 +677,121 @@ async function triggerPageGeneratedSinglePeriodArtifact(
checkpointHasDownloadId,
externallyVisibleActionMayHaveOccurred,
}));
return acquired.ok
? {
ok: true,
flowStep: {
connectorId: "gst",
scopeId: filedReturnScopeId(returnType),
state: "downloaded",
safeSignals: [...artifact.safeSignals, ...acquired.safeSignals],
safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType),
...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic
? { downloadDiagnostic: acquired.downloadDiagnostic }
: {}),
},
}
: {
ok: true,
flowStep: {
connectorId: "gst",
scopeId: filedReturnScopeId(returnType),
state: "blocked",
safeSignals: [
"artifact-acquisition-failed",
`artifact-${acquired.reason}`,
...acquired.safeSignals,
],
safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason),
},
};
if (acquired.ok) {
return {
ok: true,
flowStep: {
connectorId: "gst",
scopeId: filedReturnScopeId(returnType),
state: "downloaded",
safeSignals: [...artifact.safeSignals, ...acquired.safeSignals],
safeMessage: acquired.safeMessage ?? artifactSuccessMessage(returnType, artifactType),
...(hasDownloadDiagnostic(acquired) && acquired.downloadDiagnostic
? { downloadDiagnostic: acquired.downloadDiagnostic }
: {}),
},
};
}

const declined = await postClickBlockedStep({
artifactType,
deps,
requestId,
returnType,
scope,
tabId,
});
if (declined) {
// The retain decision above was made about an acquisition failure. This is not one: the
// portal has established that no download exists for this target, so there is nothing for a
// retry to reconcile. Leaving the intent checkpoint standing would block the next attempt as
// `artifact-acquisition-start-unreconciled` -- refusing the retry this very result offers.
retainCheckpointForRecovery = false;
return declined;
Comment thread
lamemustafa marked this conversation as resolved.
}

return {
ok: true,
flowStep: {
connectorId: "gst",
scopeId: filedReturnScopeId(returnType),
state: "blocked",
safeSignals: [
"artifact-acquisition-failed",
`artifact-${acquired.reason}`,
...acquired.safeSignals,
],
safeMessage: acquired.safeMessage ?? artifactFailureMessageForDelivery(acquired.reason),
},
};
} finally {
if (tracksBrowserDownload && !retainCheckpointForRecovery) {
await clearArtifactAcquisitionCheckpoint(checkpointTarget, requestId);
}
}
}

// The portal answers a filed GSTR-1 e-invoice Excel request with an information dialog when the
// taxpayer has no e-invoices to report. That is an answer, not a failure: there is nothing to
// download and retrying cannot change it.
//
// The content script has always been able to recognise that dialog, and the ledger has always
// known how to record the artifact as unavailable and carry the run on. Nothing sent the message
// between them, so the recognition never ran, and the run stopped on a generic failure whose only
// offered remedy was to retry something that cannot succeed.
//
// This asks, and only when an acquisition has already failed. It cannot turn a failure into a
// download: the step it returns is still `blocked`, and completion still requires correlated
// download evidence.
async function postClickBlockedStep({
artifactType,
deps,
requestId,
returnType,
scope,
tabId,
}: {
artifactType: FiledReturnsConcreteArtifactType;
deps: FiledReturnsFlowMessagingDeps;
requestId: string;
returnType: "GSTR-1" | "GSTR-2B";
scope: FiledReturnsDownloadScope;
tabId: number;
}): Promise<FlowStepResponse | null> {
const declinable =
(returnType === "GSTR-1" && artifactType === "EXCEL") || returnType === "GSTR-2B";
Comment thread
lamemustafa marked this conversation as resolved.
if (!declinable) return null;
// This runs after an acquisition has already failed, and it can only refine that failure. If the
// tab has closed, navigated, or refuses injection, the answer is simply that the failure cannot
// be refined -- so the original reason stands. Throwing here would replace a specific, actionable
// failure with the generic background error and lose the terminal summary with it.
let raw: unknown;
try {
raw = await deps.sendMessageToTabWithInjection(tabId, {
type: "PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3",
payload: {
actionId: requestId,
artifactType,
financialYear: scope.financialYear,
period: scope.period,
returnType,
},
});
} catch {
return null;
}
const response = normaliseContentScriptMessageResponse(
raw,
"PACK_CONTENT_INSPECT_FILED_RETURN_POST_CLICK_V3",
);
if (!response.ok || !("flowStep" in response)) return null;
// Only the recognised no-details answer is adopted. Any other post-click state leaves the
// original acquisition failure standing, reason intact.
return response.flowStep.safeSignals.some((signal) => DECLINED_ARTIFACT_SIGNAL_SET.has(signal))
? { ok: true, flowStep: response.flowStep }
Comment thread
lamemustafa marked this conversation as resolved.
: null;
}

async function deliverValidatedArtifact({
artifactType,
base64,
Expand Down
3 changes: 2 additions & 1 deletion src/background/filed-returns-single-period-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type Gstr1PeriodMismatchRecovery,
type ReturnTypeMismatchRecovery,
} from "./filed-returns-gstr1-period-mismatch-recovery";
import type { ReturnsDashboardAnchorFailureReason } from "../connectors/gst/filed-returns-durable-signals";

// The deadline is the product bound. This only stops a broken zero-delay response loop.
const ZERO_DELAY_RUNAWAY_STEP_LIMIT = 10_000;
Expand Down Expand Up @@ -307,7 +308,7 @@ async function blockedWrongOriginResponse(
scope: FiledReturnsDownloadScope,
deps: FiledReturnsFlowRunnerDeps,
shouldPersistSinglePeriodSummary: boolean,
reason: "ambiguous" | "not-found" | "timeout" | "unavailable",
reason: ReturnsDashboardAnchorFailureReason,
): Promise<PackMessageResponse> {
return withPersistedSinglePeriodSummary(
scope,
Expand Down
5 changes: 4 additions & 1 deletion src/background/gstr2b-artifact-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ export async function acquirePageGeneratedArtifact(input: {
controlSelector: `[data-pack-artifact-request="${input.requestId}"]`,
...(input.returnType === "GSTR-2B"
? {
expectedControlText: artifact.controlText,
// Read straight from the GSTR-2B descriptor: only that return type pins the
// control text here, and GSTR-1 has more than one label for the same artifact.
expectedControlText:
GSTR2B_PAGE_GENERATED_ARTIFACTS[input.artifactType].controlText,
expectedPeriodTexts: acceptedFiledReturnsMonthTexts(input.period),
}
: {}),
Expand Down
54 changes: 34 additions & 20 deletions src/connectors/gst/artifact-source.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { validateArtifactBytes } from "./artifact-validation";
import { describeJsonArtifactRejection, validateArtifactBytes } from "./artifact-validation";
import {
GSTR1_DETAIL_PATH,
GSTR1_PAGE_GENERATED_ARTIFACTS,
GSTR1_SUMMARY_PATH,
GSTR1_SUMMARY_PREFLIGHT_PATH,
GSTR2B_JSON_PATH,
GSTR2B_ORIGIN,
GSTR2B_PAGE_GENERATED_ARTIFACTS,
GSTR2B_SUMMARY_PATH,
findPageArtifactControls,
} from "./portal-artifact-endpoints";
import { getClickableElements, normaliseText } from "./filed-returns-dom";
import { extractScopedFiledReturnsDetailIdentity } from "./filed-returns-detail-identity";
import { filedReturnDetailIdentityMatchesScope } from "./filed-returns-detail-navigation";
import { resolveVisibleFiledReturnDownloadCandidates } from "./filed-returns-download-candidates";
Expand Down Expand Up @@ -224,14 +223,38 @@ async function acquireGstr1Artifact(
const preflightBytes = new Uint8Array(await response.arrayBuffer());
if (isHtmlResponse(preflightBytes)) return failed(request, "preflight-failed");
const preflight = validateArtifactBytes(preflightBytes, "JSON", request.returnPeriod, "GSTR-1");
if (!preflight.ok) return failed(request, preflight.reason);
const expectedPath = request.artifactType === "PDF" ? GSTR1_SUMMARY_PATH : GSTR1_DETAIL_PATH;
if (view.location.pathname !== expectedPath)
return failed(request, "wrong-page", ["target-period-verified"]);
if (!preflight.ok) {
return failed(request, preflight.reason, [
"gstr1-summary-preflight-rejected",
...describeJsonArtifactRejection(preflightBytes, request.returnPeriod, "GSTR-1"),
]);
}
const descriptor = GSTR1_PAGE_GENERATED_ARTIFACTS[request.artifactType];
const controls = resolvePageArtifactControls(documentRef, descriptor.controlText);
if (controls.length !== 1 || !controls[0])
return failed(request, "control-not-found", ["target-period-verified"]);
// The page decides which control to look for, because the same artifact is labelled differently
// on each surface that offers it. A page that is not one of those surfaces is refused.
const surface = descriptor.surfaces.find(
(candidate) => candidate.path === view.location.pathname,
);
Comment thread
lamemustafa marked this conversation as resolved.
if (!surface) {
// Named symbolically rather than by path, so the reason is diagnosable without a portal URL
// reaching a signal, a log, or an issue.
return failed(request, "wrong-page", [
"target-period-verified",
request.artifactType === "PDF"
? "gstr1-pdf-expects-summary-page"
: "gstr1-excel-expects-detail-page",
view.location.pathname === GSTR1_DETAIL_PATH ? "gstr1-on-detail-page" : "gstr1-on-other-page",
]);
}
const controls = findPageArtifactControls(documentRef, surface.controlText);
if (controls.length !== 1 || !controls[0]) {
// How many matched matters: none means the label is wrong for this page shape, several means
// the label is ambiguous and binding to one of them would be a guess.
return failed(request, "control-not-found", [
"target-period-verified",
controls.length === 0 ? "gstr1-control-label-unmatched" : "gstr1-control-label-ambiguous",
]);
}
const pageTargetMismatchSignals = gstr1PageTargetMismatchSignals(controls[0], request);
if (pageTargetMismatchSignals.length > 0) {
return failed(request, "page-period-mismatch", [
Expand Down Expand Up @@ -309,7 +332,7 @@ async function acquireGstr2bArtifact(
if (view.location.pathname !== GSTR2B_SUMMARY_PATH)
return failed(request, "wrong-page", ["target-period-verified"]);
const descriptor = GSTR2B_PAGE_GENERATED_ARTIFACTS[request.artifactType];
const controls = resolvePageArtifactControls(documentRef, descriptor.controlText);
const controls = findPageArtifactControls(documentRef, descriptor.controlText);
if (controls.length !== 1 || !controls[0])
return failed(request, "control-not-found", ["target-period-verified"]);
// The preflight above validated the fetched JSON, not the page. The summary
Expand Down Expand Up @@ -339,15 +362,6 @@ async function acquireGstr2bArtifact(
};
}

function resolvePageArtifactControls(documentRef: Document, canonicalLabel: string): HTMLElement[] {
const normalisedLabel = normaliseText(canonicalLabel);
return getClickableElements(documentRef).filter(
(element) =>
getClickableElements(element).length === 0 &&
normaliseText(element.textContent || "").includes(normalisedLabel),
);
}

function failed(
request: ArtifactRequest,
reason: ArtifactFailureReason,
Expand Down
Loading
Loading