From 26bb09e7b73616d64bfc71c3dd20b1c232cfe831 Mon Sep 17 00:00:00 2001 From: random block Date: Mon, 10 Aug 2026 12:23:54 +0100 Subject: [PATCH] fix(indexer): classify storage read failures --- .../dacs-directory/README.md | 7 ++ .../dacs-directory/src/catalog/reindexCore.ts | 20 +++-- .../dacs-directory/src/catalog/scan.ts | 76 ++++++++++++++++--- .../dacs-directory/src/catalog/store.ts | 4 + .../test/dead-letter-diagnostics.test.ts | 20 +++++ .../test/reindex-chain-reset.test.ts | 1 + .../test/scan-addresses.test.ts | 44 ++++++++++- 7 files changed, 153 insertions(+), 19 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 362baac..5fb8458 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -167,6 +167,13 @@ means the scanner could not read enough data to establish that the locator conta DACS artifact; it does not attribute a publishing failure to an agent. Raw exceptions, payloads, internal URLs and stack traces are never returned. +Storage-read diagnostics distinguish `STORAGE_NOT_FOUND`, `STORAGE_NOT_PUBLIC`, +`STORAGE_RPC_UNAVAILABLE`, and `STORAGE_INVALID_RESPONSE`. Missing and non-public +locators are terminal until a later chain replay observes them again; transient node +and response failures retain bounded retries. `STORAGE_NOT_FOUND` is operational +diagnostic evidence only: under the current Demos mapping it is never authoritative +DACS-5 absence evidence and cannot satisfy BB-8. + ## Discovery — three channels 1. **Registration** (`/register` UI or `POST /api/dacs/register`): bounded pointer sets, diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 29e3135..6b5b068 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -76,7 +76,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise 0 ? configuredMax : 100000; const configuredOverlap = Number(process.env.DACS_SCAN_REPLAY_DEPTH ?? 2); const overlap = Number.isSafeInteger(configuredOverlap) && configuredOverlap >= 0 ? configuredOverlap : 2; - const sinceTxId = needsBindingBackfill ? 0 : Math.max(0, state.lastSeenTxId - overlap); + const sinceTxId = needsHistoryReplay ? 0 : Math.max(0, state.lastSeenTxId - overlap); state.verifiedRevocations ??= {}; state.bundleBindings ??= {}; state.bundleBindingOverflow ??= []; @@ -150,7 +151,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise; + errorCode?: string; + failureCode?: StorageReadFailureCode; } -async function readStorage(address: string, attempts = 3): Promise { - for (let attempt = 1; attempt <= attempts; attempt++) try { - const res = await fetch(`${RPC}/storage-program/${address}`, { - signal: AbortSignal.timeout(15_000), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return (await res.json()) as StorageRead; - } catch { if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** (attempt - 1))); } - return null; +export type StorageReadFailureCode = + | "STORAGE_NOT_FOUND" + | "STORAGE_NOT_PUBLIC" + | "STORAGE_RPC_UNAVAILABLE" + | "STORAGE_INVALID_RESPONSE"; + +/** Safe cause classification; response bodies and upstream text are never persisted. */ +export function storageReadFailureCode(status: number, errorCode?: string): StorageReadFailureCode { + const normalized = typeof errorCode === "string" ? errorCode.trim().toUpperCase() : undefined; + if (status === 404) { + return "STORAGE_NOT_FOUND"; + } + if (status === 401 || status === 403) { + return "STORAGE_NOT_PUBLIC"; + } + // A server-side or transport-class status remains retryable even if an + // untrusted error body happens to carry a terminal-looking code. + if (status >= 500 || status < 100) return "STORAGE_RPC_UNAVAILABLE"; + if (normalized === "NOT_FOUND" || normalized === "STORAGE_NOT_FOUND" || normalized === "PROGRAM_NOT_FOUND") { + return "STORAGE_NOT_FOUND"; + } + if (normalized === "PERMISSION_DENIED" || normalized === "UNAUTHORIZED") return "STORAGE_NOT_PUBLIC"; + return status >= 200 && status < 300 ? "STORAGE_INVALID_RESPONSE" : "STORAGE_RPC_UNAVAILABLE"; +} + +export async function readStorage(address: string, attempts = 3): Promise { + let lastFailure: StorageReadFailureCode = "STORAGE_RPC_UNAVAILABLE"; + const boundedAttempts = Number.isSafeInteger(attempts) && attempts >= 1 && attempts <= 5 ? attempts : 3; + for (let attempt = 1; attempt <= boundedAttempts; attempt++) { + let res: Response; + try { + res = await fetch(`${RPC}/storage-program/${address}`, { + signal: AbortSignal.timeout(15_000), + }); + } catch { + lastFailure = "STORAGE_RPC_UNAVAILABLE"; + if (attempt < boundedAttempts) await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** (attempt - 1))); + continue; + } + + const statusFailure = storageReadFailureCode(res.status); + if (statusFailure === "STORAGE_NOT_FOUND" || statusFailure === "STORAGE_NOT_PUBLIC") { + return { success: false, failureCode: statusFailure }; + } + let body: StorageRead | null = null; + try { body = (await res.json()) as StorageRead; } catch { /* classified below */ } + const failure = storageReadFailureCode(res.status, body?.errorCode); + if (failure === "STORAGE_NOT_FOUND" || failure === "STORAGE_NOT_PUBLIC") { + return { success: false, failureCode: failure }; + } + if (res.ok && body?.success && body.programName && body.owner) return body; + lastFailure = failure; + if (attempt < boundedAttempts) await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** (attempt - 1))); + } + return { success: false, failureCode: lastFailure }; } /** @@ -421,7 +469,15 @@ export async function scanChain( for (const address of addresses) { const read = await readStorage(address); - if (!read?.success || !read.programName || !read.owner) { failures.push({ locator: address, kind: "unknown", code: "STORAGE_UNREADABLE", message: "storage program could not be read after retries" }); continue; } + if (!read?.success || !read.programName || !read.owner) { + failures.push({ + locator: address, + kind: "unknown", + code: read?.failureCode ?? "STORAGE_INVALID_RESPONSE", + message: "storage program could not be read under the public indexer policy", + }); + continue; + } const name = read.programName; programs.set(programBindingKey(read.owner, name), address); const data = read.data as Record | undefined; diff --git a/reference-implementations/dacs-directory/src/catalog/store.ts b/reference-implementations/dacs-directory/src/catalog/store.ts index fe57705..faa4ea8 100644 --- a/reference-implementations/dacs-directory/src/catalog/store.ts +++ b/reference-implementations/dacs-directory/src/catalog/store.ts @@ -423,6 +423,10 @@ export function finishScanRun(id: number, values: { toTx: number; chainTip?: num } const PUBLIC_FAILURES: Record = { STORAGE_UNREADABLE: "The storage program could not be read after repeated attempts. Confirm the locator exists and is publicly readable before retrying.", + STORAGE_NOT_FOUND: "No storage program was found at this locator. The response is operational evidence only and is not authoritative DACS absence evidence.", + STORAGE_NOT_PUBLIC: "A storage program exists at this locator but is not publicly readable. Publish it for unauthenticated reads before retrying.", + STORAGE_RPC_UNAVAILABLE: "The storage program could not be read because the public node was unavailable after bounded retries.", + STORAGE_INVALID_RESPONSE: "The public node returned a response that did not satisfy the storage-read contract after bounded retries.", }; const DACS_ARTIFACT_KINDS = new Set(["listing", "listing-revocation", "bundle", "agreement", "evidence", "verify-result", "composite", "rating"]); diff --git a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts index dc17869..52a30be 100644 --- a/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts +++ b/reference-implementations/dacs-directory/test/dead-letter-diagnostics.test.ts @@ -88,6 +88,26 @@ test("dead-letter diagnostics are safe, bounded, filterable, and recoverable", ( assert.equal(failedAgain.classification, "dacs-artifact"); }); +test("storage cause diagnostics are actionable without claiming DACS absence", () => { + const missing = locator("a"); + const privateLocator = locator("b"); + store.recordArtifactFailure(missing, "unknown", "STORAGE_NOT_FOUND", "raw 404 response", 1); + store.recordArtifactFailure(privateLocator, "unknown", "STORAGE_NOT_PUBLIC", "raw 403 response", 1); + + const missingDiagnostic = store.indexerDiagnostics({ deadLetterLocator: missing }) + .deadLetterDiagnostics.items[0]; + assert.equal(missingDiagnostic.code, "STORAGE_NOT_FOUND"); + assert.match(missingDiagnostic.message, /operational evidence only/); + assert.match(missingDiagnostic.message, /not authoritative DACS absence evidence/); + assert.doesNotMatch(JSON.stringify(missingDiagnostic), /raw 404 response/); + + const privateDiagnostic = store.indexerDiagnostics({ deadLetterLocator: privateLocator }) + .deadLetterDiagnostics.items[0]; + assert.equal(privateDiagnostic.code, "STORAGE_NOT_PUBLIC"); + assert.match(privateDiagnostic.message, /not publicly readable/); + assert.doesNotMatch(JSON.stringify(privateDiagnostic), /raw 403 response/); +}); + test("listing binding rejections are persistent, public-safe, filterable, and recoverable", () => { const target = locator("7"); const claim = `did:demos:agent:${"7".repeat(64)}`; diff --git a/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts b/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts index e934cad..a60f512 100644 --- a/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts +++ b/reference-implementations/dacs-directory/test/reindex-chain-reset.test.ts @@ -55,6 +55,7 @@ test("reindex clears replaced-chain discoveries and rescans from genesis in one const result = await reindexAll({ log: (line) => logs.push(line) }); assert.equal(result.cursor, 3, "finality depth leaves the newest two transactions for replay"); + assert.equal(store.loadScanState().schemaVersion, 8); assert.deepEqual(store.loadScanState().listings, {}); assert.equal(store.indexerDiagnostics().deadLetters, 0); assert.match(logs[0] ?? "", /chain replacement detected/); diff --git a/reference-implementations/dacs-directory/test/scan-addresses.test.ts b/reference-implementations/dacs-directory/test/scan-addresses.test.ts index 3cfffb3..c712a26 100644 --- a/reference-implementations/dacs-directory/test/scan-addresses.test.ts +++ b/reference-implementations/dacs-directory/test/scan-addresses.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { collectNativeStorageAddresses } from "../src/catalog/scan.js"; +import { + collectNativeStorageAddresses, + readStorage, + storageReadFailureCode, +} from "../src/catalog/scan.js"; const native = `stor-${"a".repeat(40)}`; const logical = `stor-${"b".repeat(64)}`; @@ -27,3 +31,41 @@ test("scanner requires a hex boundary after the native locator", () => { assert.deepEqual([...addresses], [native]); }); + +test("storage failures are classified into public-safe operational causes", () => { + assert.equal(storageReadFailureCode(404), "STORAGE_NOT_FOUND"); + assert.equal(storageReadFailureCode(403), "STORAGE_NOT_PUBLIC"); + assert.equal(storageReadFailureCode(200, "PERMISSION_DENIED"), "STORAGE_NOT_PUBLIC"); + assert.equal(storageReadFailureCode(503), "STORAGE_RPC_UNAVAILABLE"); + assert.equal(storageReadFailureCode(503, "NOT_FOUND"), "STORAGE_RPC_UNAVAILABLE"); + assert.equal(storageReadFailureCode(200), "STORAGE_INVALID_RESPONSE"); +}); + +test("terminal storage failures skip retries while transient failures remain bounded", async () => { + const originalFetch = globalThis.fetch; + let calls = 0; + try { + globalThis.fetch = async () => { + calls++; + return new Response(null, { status: 404 }); + }; + assert.deepEqual(await readStorage(native, 3), { + success: false, + failureCode: "STORAGE_NOT_FOUND", + }); + assert.equal(calls, 1); + + calls = 0; + globalThis.fetch = async () => { + calls++; + return new Response("temporarily unavailable", { status: 503 }); + }; + assert.deepEqual(await readStorage(native, 2), { + success: false, + failureCode: "STORAGE_RPC_UNAVAILABLE", + }); + assert.equal(calls, 2); + } finally { + globalThis.fetch = originalFetch; + } +});