Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2c7d303
fix(gst): bind refusals and retain uncertain acquisition state
lamemustafa Sep 12, 2026
646346e
test(gst): verify declined artifact recovery and result evidence
lamemustafa Sep 12, 2026
030b9b4
fix(gst): preserve terminal refusal recovery boundaries
lamemustafa Sep 12, 2026
39db6f5
test(gst): verify refusal persistence and resumed bundle cleanup
lamemustafa Sep 12, 2026
d72e299
fix(gst): retain mismatched recovery state
lamemustafa Sep 12, 2026
947983e
test(gst): cover durable recovery boundaries
lamemustafa Sep 12, 2026
755029a
fix(gst): keep cleanup failure summaries durable
lamemustafa Sep 12, 2026
282c48a
test(gst): persist retained cleanup state
lamemustafa Sep 12, 2026
699ae22
fix(gst): reconcile empty bundle cleanup before portal lookup
lamemustafa Sep 12, 2026
9b2515f
test(gst): exercise local absence cleanup through the entrypoint
lamemustafa Sep 12, 2026
12e22cb
fix(gst): bind bundle missing reasons to artifacts
lamemustafa Sep 12, 2026
93bcf3d
test(gst): reject mismatched bundle absence state
lamemustafa Sep 12, 2026
13ce422
fix(gst): reject conflicting bundle refusal reasons
lamemustafa Sep 12, 2026
ef887f2
test(gst): preserve canonical bundle reason storage
lamemustafa Sep 12, 2026
3aac993
refactor(gst): derive recorded refusal reasons from canonical signals
lamemustafa Sep 12, 2026
0e7c0e4
refactor(gst): centralize scoped refusal proof recognition
lamemustafa Sep 12, 2026
c5c08f6
fix(gst): require binding proof for recovered refusal completion
lamemustafa Sep 12, 2026
307d91e
test(gst): reject unproved recovered refusal summaries
lamemustafa Sep 12, 2026
87740ba
fix(popup): present stated artifact absences
lamemustafa Sep 12, 2026
d6c470f
test(popup): preserve blocked absence recovery
lamemustafa Sep 12, 2026
e169c20
fix(gst): retain bundle absence without run-bound proof
lamemustafa Sep 12, 2026
866b2f2
test(gst): preserve unproved bundle recovery for review
lamemustafa Sep 12, 2026
38e0050
fix(gst): clear only the owned durable refusal review
lamemustafa Sep 12, 2026
fa5a7b2
test(gst): cover refusal cleanup ownership and failures
lamemustafa Sep 12, 2026
59ea23b
fix(gst): require bound proof for durable not-generated
lamemustafa Sep 12, 2026
d12a2d2
test(gst): prove bound full-year refusal persistence
lamemustafa Sep 12, 2026
789d695
fix(gst): retain owned review until checkpoint cleanup succeeds
lamemustafa Sep 12, 2026
7e579b9
test(gst): cover checkpoint cleanup races and bound refusal recovery
lamemustafa Sep 12, 2026
ffe16b3
fix(gst): surface ordinary checkpoint cleanup failures
lamemustafa Sep 12, 2026
4926fdc
test(gst): cover ordinary checkpoint cleanup failures
lamemustafa Sep 12, 2026
7825a90
fix(gst): preserve ordinary checkpoint mismatch no-op
lamemustafa Sep 12, 2026
dfa0f3a
test(gst): cover checkpoint failure callers
lamemustafa Sep 12, 2026
54cd39d
test(gst): prove successful ordinary checkpoint cleanup
lamemustafa Sep 12, 2026
d57151d
fix(gst): reject staged artifacts beside refusal
lamemustafa Sep 12, 2026
dc6cd5f
test(gst): retain staged refusal evidence
lamemustafa Sep 12, 2026
437d3ed
fix(full-year): block retained artifacts on bound refusals
lamemustafa Sep 12, 2026
f0f8ed3
test(full-year): assert bounded refusal message
lamemustafa Sep 12, 2026
f10cb71
test(full-year): cover ordinary bound refusal recovery
lamemustafa Sep 12, 2026
6ca0dd0
test(full-year): assert ordinary response checkpoint
lamemustafa Sep 12, 2026
e14c0b7
fix(full-year): canonicalize retained refusal recovery
lamemustafa Sep 12, 2026
8cc1a66
fix(full-year): share retained refusal classification and recovery
lamemustafa Sep 12, 2026
3028e81
test(full-year): exercise fresh staged refusal and reopen paths
lamemustafa Sep 12, 2026
d838918
style(full-year): restore canonical ledger formatting
lamemustafa Sep 12, 2026
84d292e
test(full-year): report unexpected export as runner regression
lamemustafa Sep 12, 2026
7b50fd2
fix(bundle): preserve refusal binding proof
lamemustafa Sep 13, 2026
9d25865
feat(evidence): account for not-generated GSTR-2B periods
lamemustafa Sep 13, 2026
a6e0d50
docs(evidence): identify GSTR-2B as auto-drafted
lamemustafa Sep 13, 2026
7b69dad
style(tests): format refusal and evidence assertions
lamemustafa Sep 13, 2026
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
5 changes: 4 additions & 1 deletion docs/LIVE_EVIDENCE_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ For a one-month or full-year exploratory run, record:
each use one JSON row;
- scenario: `single-period` or `full-year`;
- outcome counts: eligible targets, downloaded, not filed, manually observed,
blocked, failed, duplicates;
blocked, failed, duplicates; schema V2 additionally records auto-drafted GSTR-2B
not-generated targets. V2 passing totals reconcile downloaded, not filed,
not generated, and manually observed targets to eligible targets. V1 evidence
remains valid without a synthesized not-generated count;
- human verification checks;
- service-worker and browser-restart checks for full-year runs;
- clear-local-data result;
Expand Down
6 changes: 5 additions & 1 deletion scripts/create-live-run-evidence-template.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ try {
for (const field of [
"downloaded",
"not-filed",
"not-generated",
"manually-observed",
"blocked",
"failed",
Expand Down Expand Up @@ -131,7 +132,7 @@ try {
const zipSha256 = options["zip-sha256"] ?? readChromeZipSha256(packageJson.version);

const evidence = {
schemaVersion: 1,
schemaVersion: 2,
evidenceId:
options["evidence-id"] ??
`pack-live-run-${new Date(startedAt).toISOString().slice(0, 10)}-${String(
Expand Down Expand Up @@ -259,6 +260,7 @@ function defaultCounts(outcome, eligibleTargets) {
return {
downloaded: eligibleTargets,
notFiled: 0,
notGenerated: 0,
manuallyObserved: 0,
blocked: 0,
failed: 0,
Expand All @@ -268,6 +270,7 @@ function defaultCounts(outcome, eligibleTargets) {
return {
downloaded: 0,
notFiled: 0,
notGenerated: 0,
manuallyObserved: 0,
blocked: outcome === "blocked" ? eligibleTargets : 0,
failed: outcome === "failed" ? eligibleTargets : 0,
Expand Down Expand Up @@ -368,6 +371,7 @@ function createDownloadEvidenceRows({

function toCamelCountKey(key) {
if (key === "not-filed") return "notFiled";
if (key === "not-generated") return "notGenerated";
if (key === "manually-observed") return "manuallyObserved";
return key;
}
Expand Down
4 changes: 3 additions & 1 deletion scripts/lib/live-run-evidence-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type LiveRunEvidenceLimitation =
| "browser-state-not-captured";

export interface LiveRunEvidence {
schemaVersion: 1;
schemaVersion: 1 | 2;
evidenceId: string;
sourceCommit: string;
gitTag: string;
Expand Down Expand Up @@ -93,6 +93,8 @@ export interface LiveRunEvidenceCounts {
blocked: number;
failed: number;
duplicates: number;
/** Required by schema V2; absent in preserved V1 evidence. */
notGenerated?: number;
}

export interface LiveRunEvidenceChecks {
Expand Down
43 changes: 26 additions & 17 deletions scripts/lib/live-run-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const LIVE_RUN_EVIDENCE_KEYS = [
"mediaArtifacts",
];
const BROWSER_KEYS = ["name", "version"];
const COUNT_KEYS = [
const COUNT_KEYS_V1 = [
"eligibleTargets",
"downloaded",
"notFiled",
Expand All @@ -94,6 +94,7 @@ const COUNT_KEYS = [
"failed",
"duplicates",
];
const COUNT_KEYS_V2 = [...COUNT_KEYS_V1, "notGenerated"];
const CHECK_KEYS = [
"humanVerifiedAccount",
"humanVerifiedPeriods",
Expand Down Expand Up @@ -192,7 +193,7 @@ export function validateLiveRunEvidence(input: unknown): LiveRunEvidenceValidati
if (!isRecord(input)) return { ok: false, errors: ["evidence must be an object"] };

requireOnlyKeys(input, LIVE_RUN_EVIDENCE_KEYS, "evidence", errors);
requireExact(input.schemaVersion, 1, "schemaVersion", errors);
requireSchemaVersion(input.schemaVersion, errors);
requirePattern(input.sourceCommit, HEX_40, "sourceCommit", errors);
requirePattern(input.gitTag, GIT_TAG, "gitTag", errors);
requirePattern(input.zipSha256, HEX_64, "zipSha256", errors);
Expand Down Expand Up @@ -225,7 +226,7 @@ export function validateLiveRunEvidence(input: unknown): LiveRunEvidenceValidati
if (input.outcome === "pass" && input.profile !== "clean-test-profile") {
errors.push("pass evidence must use clean-test-profile");
}
validateCounts(input.counts, input.outcome, errors);
validateCounts(input.counts, input.outcome, input.returnType, input.schemaVersion, errors);
validateChecks(input.checks, input.scenario, input.outcome, errors);
validateDownloadEvidence(input.downloadEvidence, input, errors);
validateLimitations(input.limitations, input.outcome, errors);
Expand Down Expand Up @@ -569,18 +570,29 @@ function expectedConcreteArtifactTypes(evidence: Record<string, unknown>): strin
);
}

function validateCounts(input: unknown, outcome: unknown, errors: string[]): void {
function validateCounts(
input: unknown,
outcome: unknown,
returnType: unknown,
schemaVersion: unknown,
errors: string[],
): void {
if (!isRecord(input)) {
errors.push("counts must be an object");
return;
}
requireOnlyKeys(input, COUNT_KEYS, "counts", errors);
for (const field of COUNT_KEYS) {
const countKeys = schemaVersion === 2 ? COUNT_KEYS_V2 : COUNT_KEYS_V1;
requireOnlyKeys(input, countKeys, "counts", errors);
for (const field of countKeys) {
requireNonNegativeInteger(input[field], `counts.${field}`, errors);
}
if (!hasOnlyNumberCounts(input)) return;
const reconciled = input.downloaded + input.notFiled + input.manuallyObserved;
if (!hasOnlyNumberCounts(input, schemaVersion)) return;
const notGenerated = schemaVersion === 2 ? input.notGenerated : 0;
const reconciled = input.downloaded + input.notFiled + notGenerated + input.manuallyObserved;
const observed = reconciled + input.blocked + input.failed;
if (notGenerated > 0 && returnType !== "GSTR-2B") {
errors.push("counts.notGenerated can be nonzero only for GSTR-2B");
}
if (outcome === "pass" && reconciled === 0) {
errors.push("counts must include at least one reconciled target");
} else if (observed === 0) {
Expand Down Expand Up @@ -736,13 +748,8 @@ function assertNoSensitiveMarkers(input: unknown, errors: string[]): void {
}
}

function requireExact(
value: unknown,
expected: number | string | boolean,
field: string,
errors: string[],
): void {
if (value !== expected) errors.push(`${field} must be ${String(expected)}`);
function requireSchemaVersion(value: unknown, errors: string[]): void {
if (value !== 1 && value !== 2) errors.push("schemaVersion must be 1 or 2");
}

function requirePattern(
Expand Down Expand Up @@ -817,15 +824,17 @@ function stableJson(value: unknown): string {

function hasOnlyNumberCounts(
input: Record<string, unknown>,
): input is Record<keyof LiveRunEvidenceCounts, number> {
schemaVersion: unknown,
): input is Record<keyof LiveRunEvidenceCounts, number> & { notGenerated?: number } {
return (
typeof input.downloaded === "number" &&
typeof input.eligibleTargets === "number" &&
typeof input.notFiled === "number" &&
typeof input.manuallyObserved === "number" &&
typeof input.blocked === "number" &&
typeof input.failed === "number" &&
typeof input.duplicates === "number"
typeof input.duplicates === "number" &&
(schemaVersion !== 2 || typeof input.notGenerated === "number")
);
}

Expand Down
100 changes: 73 additions & 27 deletions src/background/artifact-acquisition-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ const ARTIFACT_ACQUISITION_CHECKPOINT_BASE_KEYS = [
"state",
] as const;

let artifactAcquisitionCheckpointMutationCriticalSection = Promise.resolve();

export type ArtifactAcquisitionTarget = Pick<
FiledReturnsDownloadScope,
"artifactType" | "financialYear" | "period" | "returnType"
Expand Down Expand Up @@ -182,52 +184,96 @@ export async function clearMalformedArtifactAcquisitionCheckpoint(
export async function persistArtifactAcquisitionIntent(
input: Omit<ArtifactAcquisitionCheckpoint, "armedAt" | "state" | "downloadId">,
): Promise<void> {
const key = artifactAcquisitionCheckpointKey(input);
await browser.storage.session.set({
[key]: {
...input,
armedAt: new Date().toISOString(),
state: "intent",
} satisfies ArtifactAcquisitionCheckpoint,
await runArtifactAcquisitionCheckpointMutation(async () => {
const key = artifactAcquisitionCheckpointKey(input);
await browser.storage.session.set({
[key]: {
...input,
armedAt: new Date().toISOString(),
state: "intent",
} satisfies ArtifactAcquisitionCheckpoint,
});
});
}

export async function persistArtifactAcquisitionDownloadId(
input: Omit<ArtifactAcquisitionCheckpoint, "armedAt">,
): Promise<void> {
const key = artifactAcquisitionCheckpointKey(input);
const stored = await browser.storage.session.get(key);
await browser.storage.session.set({
[key]: {
...input,
armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(),
state: "download-observing",
} satisfies ArtifactAcquisitionCheckpoint,
await runArtifactAcquisitionCheckpointMutation(async () => {
const key = artifactAcquisitionCheckpointKey(input);
const stored = await browser.storage.session.get(key);
await browser.storage.session.set({
[key]: {
...input,
armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(),
state: "download-observing",
} satisfies ArtifactAcquisitionCheckpoint,
});
});
}

export async function persistArtifactAcquisitionUnconfirmedDownload(
input: Omit<ArtifactAcquisitionCheckpoint, "armedAt">,
): Promise<void> {
const key = artifactAcquisitionCheckpointKey(input);
const stored = await browser.storage.session.get(key);
await browser.storage.session.set({
[key]: {
...input,
armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(),
state: "download-unconfirmed",
} satisfies ArtifactAcquisitionCheckpoint,
await runArtifactAcquisitionCheckpointMutation(async () => {
const key = artifactAcquisitionCheckpointKey(input);
const stored = await browser.storage.session.get(key);
await browser.storage.session.set({
[key]: {
...input,
armedAt: armedAtFromCheckpoint(stored[key]) ?? new Date().toISOString(),
state: "download-unconfirmed",
} satisfies ArtifactAcquisitionCheckpoint,
});
});
}

export async function clearArtifactAcquisitionCheckpoint(
target: ArtifactAcquisitionTarget,
requestId: string,
): Promise<{ ok: true } | { ok: false; reason: ArtifactAcquisitionCheckpointClearFailureReason }> {
return runArtifactAcquisitionCheckpointMutation(async () => {
const key = artifactAcquisitionCheckpointKey(target);
let stored: Record<string, unknown>;
try {
stored = await browser.storage.session.get(key);
} catch {
return { ok: false, reason: "storage-read-failed" };
Comment thread
lamemustafa marked this conversation as resolved.
}
if ((stored[key] as { requestId?: unknown } | undefined)?.requestId !== requestId) {
return { ok: false, reason: "checkpoint-invalid" };
}
try {
await browser.storage.session.remove(key);
} catch {
return { ok: false, reason: "storage-remove-failed" };
}
return { ok: true };
});
}

/** Clears an ordinary acquisition checkpoint, preserving the prior rejecting contract. */
export async function clearArtifactAcquisitionCheckpointOrThrow(
target: ArtifactAcquisitionTarget,
requestId: string,
): Promise<void> {
const key = artifactAcquisitionCheckpointKey(target);
const stored = await browser.storage.session.get(key);
if ((stored[key] as { requestId?: unknown } | undefined)?.requestId === requestId) {
await browser.storage.session.remove(key);
const result = await clearArtifactAcquisitionCheckpoint(target, requestId);
if (!result.ok && result.reason !== "checkpoint-invalid") {
throw new Error(`artifact acquisition checkpoint clear failed: ${result.reason}`);
}
}

async function runArtifactAcquisitionCheckpointMutation<T>(action: () => Promise<T>): Promise<T> {
const previous = artifactAcquisitionCheckpointMutationCriticalSection;
let release: () => void = () => undefined;
artifactAcquisitionCheckpointMutationCriticalSection = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await action();
} finally {
release();
}
}

Expand Down
42 changes: 42 additions & 0 deletions src/background/background-failure-fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 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 keeps durable failure fingerprints useful without retaining untrusted error text.

// 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;
}
Loading
Loading