From f87b97407cbed98a9e87521acf79f715d12becbb Mon Sep 17 00:00:00 2001 From: mj-deving Date: Sun, 2 Aug 2026 09:19:38 +0200 Subject: [PATCH 1/3] fix(directory): bound revocation candidate state --- .../dacs-directory/README.md | 2 + .../dacs-directory/src/catalog/reindexCore.ts | 50 ++++++++++++++++- .../dacs-directory/src/catalog/scan.ts | 55 ++++++++++++++++--- .../dacs-directory/src/catalog/types.ts | 4 +- .../dacs-directory/test/verification.test.ts | 45 ++++++++++++++- 5 files changed, 143 insertions(+), 13 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index bcb2a39..f14acda 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -176,6 +176,8 @@ payloads, internal URLs and stack traces are never returned. to sellers via the buyer-anchored agreement. Agents nobody registered appear as "discovered on-chain". Depth: `DACS_SCAN_MAX_TXS` (default 100000); a pass that hits the cap fails rather than advancing the cursor and silently skipping history. + Revocation discovery retains at most 16 candidates per listing hash, except for + locators that already passed RB-4 verification; truncation is recorded in reindex logs. 3. **Evidence graph**: current bundles recursively resolve and validate listings, agreements, settlement evidence and amendment chains, composite/VerifyResult vet records, and ratings. Legacy SDK artifacts remain on an explicitly-labelled diff --git a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts index 386f7c6..37b8f7f 100644 --- a/reference-implementations/dacs-directory/src/catalog/reindexCore.ts +++ b/reference-implementations/dacs-directory/src/catalog/reindexCore.ts @@ -5,7 +5,7 @@ * and by POST /api/dacs/reindex (the UI's refresh button). */ import { indexRegistration, type ResolveIdentities } from "./indexer"; -import { readChainTip, scanChain } from "./scan"; +import { boundedRevocationCandidates, readChainTip, scanChain } from "./scan"; import { chainResetRequired, chainResetThreshold } from "./chainContinuity"; import { crawlDomains } from "./wellknown"; import { upsertCounterpartyEvidenceSeller } from "./counterpartyEvidence"; @@ -73,6 +73,7 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise= 0 ? configuredOverlap : 2; const sinceTxId = needsBindingBackfill ? 0 : Math.max(0, state.lastSeenTxId - overlap); + state.verifiedRevocations ??= {}; + for (const seller of prior.sellers) for (const listing of seller.listings) { + const locator = listing.revocationBinding?.markerAnchor.locator; + if (!locator) continue; + const verified = state.verifiedRevocations[listing.contentHash] ?? []; + if (!verified.includes(locator)) verified.push(locator); + state.verifiedRevocations[listing.contentHash] = verified; + } + const verifiedRevocations = new Map( + Object.entries(state.verifiedRevocations).map(([hash, addresses]) => [hash, new Set(addresses)]), + ); const runId = beginScanRun(sinceTxId); let scan; try { - scan = await scanChain(null, { maxTxs, sinceTxId, retryLocators: loadRetryableArtifacts() }); + scan = await scanChain(null, { maxTxs, sinceTxId, retryLocators: loadRetryableArtifacts(), verifiedRevocations }); } catch (error) { finishScanRun(runId, { toTx: state.lastSeenTxId, txs: 0, artifacts: 0, rejected: 0, error: error instanceof Error ? error.message : String(error) }); throw error; @@ -111,12 +123,26 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise 0) { + log(`revocation candidates: truncated ${revocationCandidatesTruncated} unverified locator(s) at the per-listing bound`); + } const didOf = (addr: string) => `did:demos:agent:${addr.replace(/^0x/, "")}`; const known = new Set(regs.map((r) => r.primaryClaim)); @@ -215,6 +244,21 @@ export async function reindexAll(opts: ReindexOptions = {}): Promise; /** owner + programName → observed native address. */ programs: Map; - /** listing content hash → every observed revocation marker candidate. */ + /** listing content hash → bounded, deterministic revocation candidates. */ revocations: Map; + /** Candidate locators discarded by the per-listing resource bound. */ + revocationCandidatesTruncated: number; txsScanned: number; /** Highest tx id observed — the next pass's cursor. */ highestTxId: number; @@ -94,10 +96,36 @@ export function addRevocationCandidate( revocations: Map, listingHash: string, address: string, -): void { + verifiedAddresses: ReadonlySet = new Set(), +): number { const candidates = revocations.get(listingHash) ?? []; - if (!candidates.includes(address)) candidates.push(address); - revocations.set(listingHash, candidates); + const merged = boundedRevocationCandidates([...candidates, address], verifiedAddresses); + revocations.set(listingHash, merged.candidates); + return merged.truncated; +} + +export const MAX_REVOCATION_CANDIDATES_PER_LISTING = 16; + +/** + * Keep discovery state deterministic and bounded while never evicting a marker + * that already passed RB-4 verification. Verified markers are signer-controlled + * rather than public-shape-controlled, so they form the explicit bound exception. + */ +export function boundedRevocationCandidates( + addresses: Iterable, + verifiedAddresses: ReadonlySet = new Set(), + limit = MAX_REVOCATION_CANDIDATES_PER_LISTING, +): { candidates: string[]; truncated: number } { + // Include the persisted verified set even when an older scan-state snapshot + // omitted that locator from its candidate array. + const unique = [...new Set([...verifiedAddresses, ...addresses])]; + const verified = unique.filter((address) => verifiedAddresses.has(address)); + const unverified = unique.filter((address) => !verifiedAddresses.has(address)); + const candidates = [ + ...verified, + ...unverified.slice(0, Math.max(0, limit - verified.length)), + ]; + return { candidates, truncated: unique.length - candidates.length }; } /** @@ -163,7 +191,12 @@ export async function readChainTip(): Promise { */ export async function scanChain( _demos: unknown, - opts: { maxTxs?: number; sinceTxId?: number; retryLocators?: string[] } = {}, + opts: { + maxTxs?: number; + sinceTxId?: number; + retryLocators?: string[]; + verifiedRevocations?: ReadonlyMap>; + } = {}, ): Promise { // Incremental: walk latest → sinceTxId (exclusive) and stop. First run // (no cursor) backfills the whole history up to maxTxs. @@ -218,6 +251,7 @@ export async function scanChain( const listings = new Map(); const programs = new Map(); const revocations = new Map(); + let revocationCandidatesTruncated = 0; const observations: ScannedArtifacts["observations"] = []; const failures: ScannedArtifacts["failures"] = []; const bundleOwners = new Map(); // jobId → buyer bundle @@ -239,7 +273,13 @@ export async function scanChain( let artifactKind = "other"; if (isListingRevocationCandidate(data)) { artifactKind = "listing-revocation"; - addRevocationCandidate(revocations, String(data!.listingContentHash).toLowerCase(), address); + const listingHash = String(data!.listingContentHash).toLowerCase(); + revocationCandidatesTruncated += addRevocationCandidate( + revocations, + listingHash, + address, + opts.verifiedRevocations?.get(listingHash), + ); } else if (name.startsWith("dacs1:listing:") || name.startsWith("dacs1-") || currentListing) { artifactKind = "listing"; listings.set(address, read.owner); @@ -287,5 +327,6 @@ export async function scanChain( }); } - return { listings, deals, programs, revocations, txsScanned: scanned, highestTxId, complete, chainTip, observations, failures, scanError }; + return { listings, deals, programs, revocations, revocationCandidatesTruncated, + txsScanned: scanned, highestTxId, complete, chainTip, observations, failures, scanError }; } diff --git a/reference-implementations/dacs-directory/src/catalog/types.ts b/reference-implementations/dacs-directory/src/catalog/types.ts index 2c4eb8a..2fff9cd 100644 --- a/reference-implementations/dacs-directory/src/catalog/types.ts +++ b/reference-implementations/dacs-directory/src/catalog/types.ts @@ -234,8 +234,10 @@ export interface ScanState { cursorAdvancedAt?: number; /** owner + programName → observed native address (nonce-safe binding). */ programs?: Record; - /** listing content hash → every observed revocation marker candidate. */ + /** listing content hash → bounded, deterministic revocation candidates. */ revocations?: Record; + /** RB-4-verified marker locators that candidate pruning must preserve. */ + verifiedRevocations?: Record; /** listing anchor address → owner address */ listings: Record; /** jobId → discovered deal */ diff --git a/reference-implementations/dacs-directory/test/verification.test.ts b/reference-implementations/dacs-directory/test/verification.test.ts index 1be04e2..079393e 100644 --- a/reference-implementations/dacs-directory/test/verification.test.ts +++ b/reference-implementations/dacs-directory/test/verification.test.ts @@ -28,7 +28,11 @@ import { revocationLogicalAddress, verifyListing, } from "../src/catalog/listingVerification.js"; -import { addRevocationCandidate, isListingRevocationCandidate } from "../src/catalog/scan.js"; +import { + addRevocationCandidate, + isListingRevocationCandidate, + MAX_REVOCATION_CANDIDATES_PER_LISTING, +} from "../src/catalog/scan.js"; import { deriveSellerReputation, flipOutcome } from "../src/catalog/reputation.js"; import type { Catalog, DealRecord, SellerRecord } from "../src/catalog/types.js"; import type { BundleVerification } from "../vendor/dacs-sdk/dist/agent/verifyBundleCore.js"; @@ -518,7 +522,7 @@ test("evidence ref must be signed by a bundle party", async () => { ); }); -test("any valid revocation candidate wins and scanner candidates accumulate", async () => { +test("any valid revocation candidate wins and scanner candidates deduplicate", async () => { const listingMessage = Buffer.from(`dacs-listing:v1:${contentHash(listing)}`, "utf8"); const listingSignature = Buffer.from( await ed25519Sign(listingMessage, privateKeyFromSeed(seed)), @@ -571,6 +575,43 @@ test("any valid revocation candidate wins and scanner candidates accumulate", as assert.equal(isListingRevocationCandidate({ ...valid, listingContentHash: "not-a-hash" }), false); }); +test("revocation candidate discovery is bounded per listing hash", () => { + const candidates = new Map(); + const listingHash = "a".repeat(64); + for (let index = 0; index < 20; index++) { + addRevocationCandidate(candidates, listingHash, `stor-${index.toString(16).padStart(40, "0")}`); + } + assert.equal(candidates.get(listingHash)?.length, MAX_REVOCATION_CANDIDATES_PER_LISTING); +}); + +test("candidate pruning preserves an RB-4-verified locator across restart", () => { + const listingHash = "b".repeat(64); + const verified = `stor-${"f".repeat(40)}`; + const beforeRestart = new Map(); + for (let index = 0; index < MAX_REVOCATION_CANDIDATES_PER_LISTING; index++) { + addRevocationCandidate(beforeRestart, listingHash, `stor-${index.toString(16).padStart(40, "0")}`); + } + addRevocationCandidate(beforeRestart, listingHash, verified, new Set([verified])); + + const persisted = JSON.parse(JSON.stringify(Object.fromEntries(beforeRestart))) as Record; + const afterRestart = new Map(Object.entries(persisted)); + for (let index = 16; index < 32; index++) { + addRevocationCandidate( + afterRestart, + listingHash, + `stor-${index.toString(16).padStart(40, "0")}`, + new Set([verified]), + ); + } + + assert.equal(afterRestart.get(listingHash)?.length, MAX_REVOCATION_CANDIDATES_PER_LISTING); + assert.equal(afterRestart.get(listingHash)?.includes(verified), true); + + const staleState = new Map([[listingHash, afterRestart.get(listingHash)!.filter((address) => address !== verified)]]); + addRevocationCandidate(staleState, listingHash, `stor-${"e".repeat(40)}`, new Set([verified])); + assert.equal(staleState.get(listingHash)?.includes(verified), true); +}); + test("public discovery excludes revoked listings and empty sellers", () => { const listingSummary = (status: "active" | "revoked") => ({ listingId: status, From 89f970dd5452378b8247825ba8a72f1d69be2e31 Mon Sep 17 00:00:00 2001 From: mj-deving Date: Sun, 2 Aug 2026 09:23:23 +0200 Subject: [PATCH 2/3] docs(directory): document revocation window boundary --- reference-implementations/dacs-directory/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index f14acda..2c7b982 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -178,6 +178,9 @@ payloads, internal URLs and stack traces are never returned. hits the cap fails rather than advancing the cursor and silently skipping history. Revocation discovery retains at most 16 candidates per listing hash, except for locators that already passed RB-4 verification; truncation is recorded in reindex logs. + New scan observations precede prior unverified state in that window. A valid marker + outside the retained window is not evaluated, so its publisher must anchor a fresh + marker; after one marker verifies, later pruning cannot make it disappear. 3. **Evidence graph**: current bundles recursively resolve and validate listings, agreements, settlement evidence and amendment chains, composite/VerifyResult vet records, and ratings. Legacy SDK artifacts remain on an explicitly-labelled From c4f382d6e73a310614dff35e486cdbce58be592f Mon Sep 17 00:00:00 2001 From: mj-deving Date: Sun, 2 Aug 2026 09:25:06 +0200 Subject: [PATCH 3/3] docs(directory): clarify candidate selection liveness --- reference-implementations/dacs-directory/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index 2c7b982..eb16351 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -178,9 +178,11 @@ payloads, internal URLs and stack traces are never returned. hits the cap fails rather than advancing the cursor and silently skipping history. Revocation discovery retains at most 16 candidates per listing hash, except for locators that already passed RB-4 verification; truncation is recorded in reindex logs. - New scan observations precede prior unverified state in that window. A valid marker - outside the retained window is not evaluated, so its publisher must anchor a fresh - marker; after one marker verifies, later pruning cannot make it disappear. + New scan observations precede prior unverified state in that window; within each + group, the first distinct locators in scan iteration order survive. A valid marker + outside the retained window is not evaluated. Its publisher can anchor a fresh + marker to re-enter discovery, but continued overflow can exclude that marker again. + After one marker verifies, later pruning cannot make it disappear. 3. **Evidence graph**: current bundles recursively resolve and validate listings, agreements, settlement evidence and amendment chains, composite/VerifyResult vet records, and ratings. Legacy SDK artifacts remain on an explicitly-labelled