diff --git a/reference-implementations/dacs-directory/README.md b/reference-implementations/dacs-directory/README.md index bcb2a39..eb16351 100644 --- a/reference-implementations/dacs-directory/README.md +++ b/reference-implementations/dacs-directory/README.md @@ -176,6 +176,13 @@ 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. + 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 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,