feat(dRAG): Decentralized GraphRAG V1 — verifiable, cross-node, monetizable answering (OT-RFC-55)#1314
feat(dRAG): Decentralized GraphRAG V1 — verifiable, cross-node, monetizable answering (OT-RFC-55)#1314branarakic wants to merge 21 commits into
Conversation
Compose the SHIPPED V10 primitives into one auditable citation object
{UAL, node, author-sig, merkle, on-chain}:
- core/crypto/citation.ts: wire type + pure Merkle/content-binding verify
(reuses verifyV10ProofMaterial; keccak V10 tree, not the sha256 oracle
path, so a citation that verifies here verifies on-chain by construction).
- agent/drag/citation.ts: producer (extractV10KCFromStore -> buildV10ProofMaterial
re-anchored to getLatestMerkleRoot) + EIP-712 author-seal recovery (ethers),
with on-chain author as the authoritative fallback when the _meta seal is absent.
17 unit tests: pure proof + content-binding + tamper detection (core),
real-wallet EIP-712 recovery + live re-anchor + authorSig fallback (agent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…55 P2) question -> keyword retrieval over per-KA verifiable-memory graphs -> canonical triples -> a VerifiableCitation per cited fact. No node-side LLM required (keyword/structural baseline; LLM synthesis is a future enhancement). - agent: DragMethods.dragAnswerLocal mixin; citation producer split into prepareKaCitation (extract + chain reads + seal, once per KA) + citeTriple (pure proof per fact). Author seal loaded from the name-scoped _meta graph. - cli: POST /api/answer route (routes/drag.ts) wired into the dispatch chain. - mcp-dkg: dkg_answer tool + DkgClient.answer() + DragAnswerResult types. Validated on a live 4-node devnet: every cited fact returns author-sig OK + merkle OK + on-chain OK (re-anchored to the live root). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scope:"network" answers one question across the nodes serving a PUBLIC context graph, then RE-VERIFIES every citation against the asker's own chain — so the asker trusts no serving node's self-reported verdict, and can answer over knowledge it does not hold. - core: PROTOCOL_DRAG_ANSWER libp2p protocol constant. - agent: DiscoveryClient.findNodesServingCG (reads the contextGraphsServed phonebook = the §5.1 context-oracle index); PROTOCOL_DRAG_ANSWER handler (public-CG only, fail-closed via isContextGraphPublicOnChain); dragAnswerRemote (one peer) + dragAnswerNetwork (fan out, dedup, per-key re-verify, per-node trust breakdown). Optional explicit "peers" override for when an advertisement has not yet gossiped into the local phonebook. - cli/mcp: scope + peers plumbed through /api/answer, DkgClient.answer, dkg_answer. Validated on a live 4-node devnet: an asker holding NO copy of the CG assembled a 3-fact answer from 2 remote serving nodes, every citation re-verified (author-sig + merkle + on-chain) against the chain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the monetization rail without a real facilitator: public CGs stay FREE in V1, but the HTTP 402 challenge + X-PAYMENT format + a pluggable PaymentVerifier are in place so the Coinbase/USDC facilitator drops in behind one interface. - cli/daemon/payment.ts: x402 wire types, PaymentVerifier interface, MockPaymentVerifier (accept-any dev/CI verifier returning a synthetic receipt), parsePrice / parseXPaymentHeader / build402Body, and the pure resolvePayment gate (free | challenge | paid). - routes/drag.ts: localized payment gate (does NOT touch the central request chain). simulatePrice exercises 402 -> pay -> 200+receipt with the mock; real per-CG pricing + facilitator deferred. - mcp: settlement receipt surfaced on DragAnswerResult + the dkg_answer summary. 14 unit tests (parse/verify/gate). Live devnet: priced request 402s without payment, returns 200 + receipt with a valid X-PAYMENT, and the paid answer is still fully verifiable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… binding) Fixes the confirmed findings from the multi-dimension review of P1-P4. HIGH: - Remote PROTOCOL_DRAG_ANSWER handler is unauthenticated (libp2p peers carry no token): add a per-peer rate limit (30/min) + clamp the remote path to a small cost ceiling (maxKas/maxCitations=15, vs 100/50 locally) so one cheap request can't trigger a heavy store scan + hundreds of chain reads. - dragAnswerNetwork: cap the peer fan-out (<=24) + bound concurrency (8) so a caller-supplied peers[] can't make this node a reflector; cap citations re-verified per peer (<=64) so one peer's large response can't exhaust the asker's RPC budget. - Validate each peer response shape (isValidDragAnswerResult) before use + guard each citation in a try/catch, so one malformed/version-skewed peer becomes a per-node error instead of failing the whole answer. MEDIUM: - CG-scope binding: re-verification now confirms each citation's KA belongs to the asked context graph (getKAContextGraphId) and stamps the asker-derived CG id — a peer can no longer pass off a verifiable fact from a DIFFERENT KA. - Verdict cache keyed on the proof (not just the fact) + dedup prefers a verified citation, so a bad proof can't poison or suppress an honest one. LOW: - validateContextGraphId before any SPARQL interpolation (parity + defense-in-depth). - verifyVerifiableCitation re-anchors the leaf count against getMerkleLeafCount (the pure check was tautological). Re-validated on a live 4-node devnet (P2 local + P4 402-pay + P3 network all green); +1 unit test for the leaf-count re-anchor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- scripts/drag-demo.mjs: a 4-act narrated walk-through (verifiable → cross-node → tamper-proof → x402-paid) runnable against a local devnet. - node-ui: a "dRAG Ask" panel (sidebar entry + top-level view) that POSTs /api/answer and renders the grounded answer with per-citation verification badges (author-sig / merkle / on-chain), a per-serving-node breakdown for scope:network, and the x402 402→pay→200 settlement receipt. New api.answerQuestion() helper handles the paywall round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d scan (OT-RFC-55) The keyword CONTAINS filter was O(N) and lexical — it misses paraphrase/synonym matches and an agent rarely knows the exact words in the data. Phase 1 makes the NL→KAs path a neurosymbolic retrieval: embed the question → ANN over the CG's indexed entities → anchors → 1-hop graph expansion → the same verifiable citations. Keyword stays as the fallback. - vector-store.ts: HashingEmbeddingProvider (zero-dep, offline, LEXICAL — a ranked ANN path with no model, and the deterministic CONTRAST baseline) + LocalEmbeddingProvider (real OFFLINE semantic via a runtime dynamic-import of @huggingface/transformers / MiniLM — an OPT-IN dep, not committed; clear error + hashing fallback if absent). OpenAIEmbeddingProvider unchanged. count() gains a per-model filter for index freshness. - agent: dependency-free EntityRetriever interface (drag/retriever.ts) + an attach point on the agent, so BOTH the local route and the network serving handler get semantic. dragAnswerLocal: embed→ANN→anchor→1-hop graph expansion (follow object-IRIs to neighbour entities) → cite; keyword fallback when no retriever; `retrieval` surfaced in stats. - cli: VectorEntityRetriever (lazy build-then-search index over VM entities, with a freshness check + idempotent re-index). lifecycle attaches it ONLY when a real model is configured (DKG_DRAG_EMBEDDER=local|openai or an OpenAI key); default stays keyword (predictable) — hashing is an explicit control. /api/answer gains an `embedder` override (keyword|hashing|local|openai) for A/B comparison. Live devnet acceptance (paraphrase data with NO query-word overlap, query "which suppliers were flagged in the audit?"): keyword → 0; hashing → the WRONG suppliers (0/2, lexical noise); local MiniLM → the actual compliance failures (2/2). 1-hop: "summarize the early-2026 vendor quality review" matched the review entity and followed coversVendor to reach a supplier the question never named. All offline. +5 embedder unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mo script - node-ui dRAG Ask panel: a "retrieval" selector (default | keyword | lexical | semantic) that flips the per-request embedder, plus a chip showing which path ran — so you can watch a question go from 0 facts (keyword) to a ranked, cited answer (semantic) on the same data. api.answerQuestion gains `embedder`. - scripts/drag-demo-semantic.mjs: a narrated A/B (keyword misses · lexical ranks wrong · MiniLM ranks the real failures) + a 1-hop GraphRAG step, over paraphrase-gap data. Needs `npm i @huggingface/transformers`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Release-gate suite against a live devnet, one scenario each: P2 cited answer (every citation verified), P1 client-side verify + tamper-rejection, P3 cross-node fan-out re-verified by an asker holding nothing, P4 x402 402 challenge, and the Phase-1 split — keyword finds the literal-'flagged' supplier but MISSES the paraphrase one, the vector path retrieves, and semantic (MiniLM, when the optional model is installed) reaches the paraphrase supplier. `pnpm test:devnet:drag-v1`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…by default
Productionize the dRAG answer contract (OT-RFC-55 steps 2+3):
- config.drag: { embedder, embedderModel/BaseURL/ApiKey, payments.enabled,
maxKas, maxCitations, experimentalOverrides }. A new drag-embedder factory
centralises provider selection so the node default (lifecycle) and the
per-request semantic path (route) agree.
- Public answer param is now retrieval: "default" | "keyword" | "semantic".
semantic resolves a model hard (configured -> openai-if-creds -> local),
so an agent that asks for semantic gets it when a model is reachable.
Threaded through the dkg_answer MCP tool + client.
- The raw embedder override + simulatePrice are now honoured ONLY under
config.drag.experimentalOverrides, keeping demo knobs out of the public API.
- Payments OFF by default: a priced request is answered for free with no
settlement unless config.drag.payments.enabled (and experimentalOverrides).
Integration suite updated to the public param + payments-off default; 7/7 green
on a fresh devnet (keyword -> northwind only; semantic -> reaches initech).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers POST /api/answer + the dkg_answer MCP tool, scope (local/network), retrieval (default/keyword/semantic), embedder configuration (local Ollama and hosted OpenAI via embedderBaseURL as the no-heavy-dep semantic path; offline MiniLM as the opt-in), payments-off default, the index-is-an-untrusted-hint model, and the brute-force -> sqlite-vec scaling path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- VectorStore.listIds(cg, model): the indexer now skips entities already embedded, so a re-scan embeds only newly-published entities instead of re-embedding the whole graph (embedding is the expensive step). Unit test proves the delta-only behaviour (cold: 2 entities embedded; +1 publish: only 1 re-embedded; no change: 0). - VectorEntityRetriever.warm(cg): best-effort, never-throws, incremental. Hooked into emitMemoryGraphChanged so the index warms right after a publish — the first query against fresh facts no longer pays the embedding cost. - EntityRetriever gains an optional warm() in the agent-side interface. ANN: brute-force cosine is retained (fine to ~100k vectors/node; embedding, not search, dominates) with the sqlite-vec/pgvector swap documented at the search site + in the dRAG guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Step 6 — productionize the answer surface (precision cutoff intentionally NOT added; ranking is left to top-K + the consumer, documented as a known limitation per review): - Observability: GET /api/answer/metrics (answersServed, byMode, citationsVerified, retrievalDegraded, synthesized) + stats.latencyMs on every answer. - retrievalDegraded: the retriever now reports when the embedder could not run (e.g. local model not installed); the answer says "semantic unavailable on this node" with stats.retrievalDegraded=true instead of a silent empty. - Optional grounded synthesis: synthesize:true (local scope, config.llm) composes prose from ONLY the verified facts — never mutates facts/citations, falls back to the structured digest on any failure. Default off; structured stays the machine-readable default for consuming agents. - warm() coalesces concurrent warms (write bursts) via an in-flight guard. 9 dRAG unit tests green (retriever incremental, embedder, synthesize-with-stubbed-LLM). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A multi-agent adversarial review of the productionization diff raised 20, 13 confirmed. All fixed (one test gap documented-deferred): HIGH - Incremental-index freshness gate compared distinct SUBJECTS to (graph,subject) ROW count, so once a subject recurred across VM graphs the gate stayed satisfied and freshly-published entities were never embedded (silent under-indexing). Now counts distinct (g,s) PAIRS to match insert granularity. Regression test reproduces the exact shared-subject scenario. - node-UI dRAG panel still sent the now-gated `embedder` knob, so its retrieval dropdown was a silent no-op on a default node (this is the UI "nothing happens" symptom). Migrated to the public `retrieval` param (default|keyword|semantic). - P3 trustless aggregation had only happy-path coverage. New adversarial unit test feeds dragAnswerNetwork a lying peer (tampered triple carrying verified:true → asker re-verifies and rejects), an off-scope genuine citation (dropped by the CG-scope-swap defense), and a throwing peer (isolated as a per-node error) — proving the asker never trusts a peer's verdict. MEDIUM - resolveSemanticEmbedder returned null for embedder:'openai' without creds instead of falling through to the local model → explicit retrieval:'semantic' silently became keyword. Now falls through. - `retrieval` documented as local-scope-only (route header + MCP tool + client); network peers answer with their own retrieval. - Semantic devnet tests used a bare `return` (reported as PASSED). Now ctx.skip() so they report SKIPPED; P4 payment test re-scoped to honestly assert the payments-off default (no false "price-declining" claim). - Degraded-signal unit test added (embedder throws → degraded=true, no anchors). LOW - VectorStore.search now filters by model (no cross-embedding-space scoring). - Synthesis prompt fences facts in an <verified_facts> untrusted-data block + system instruction to never follow embedded instructions; per-fact length cap. - Demo scripts moved to the public `retrieval` param; payment demo degrades gracefully when payments are off. - Fixed the stale lifecycle embedder-default comment (keyword, not hashing). Deferred (documented): a live-daemon route-level synthesis test (the synthesizeAnswer function is unit-tested; the repo avoids fabricated-agent route stubs). Unit suites + the devnet/drag-v1 integration suite (7/7) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fix commit (3664c70) had not itself been through the adversarial gate. A focused review (15 raised, 8 confirmed) found one real defect; the rest were positive verifications (the freshness-pairs fix, the search model-filter binding, the embedder fallthrough, and the new adversarial/degraded tests were all confirmed correct via mutation testing) plus minor cleanups: MEDIUM - Freshness gate compared a VM-only source count against vectorStore.count(), which counts ALL memory layers — so WM/SWM agent-memory vectors on the same CG under the same model (the /api/memory/turn path) inflate the indexed count and re-introduce the silent under-indexing of fresh VM entities (same bug class as the subject-vs-pairs fix, on the denominator side). count() now takes an optional memoryLayer; ensureIndexed scopes it to 'vm' so both sides count the same rows. Regression test (a WM row must not satisfy the VM gate) added. LOW - resolveSemanticEmbedder can no longer return null → dropped the stale `| null`. - Synthesis now strips the `<verified_facts>` delimiter from (attacker- controllable) fact content so a literal can't close the fence early. - Degraded test now also asserts degraded===false on the working-embedder path (pins both halves of the "no model" vs "no matches" distinction). - Fixed the drag-demo-semantic.mjs header (two retrievers now, not three). cli + agent dRAG units green; devnet/drag-v1 integration 7/7 on the live build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The round-2 `<verified_facts>` delimiter strip used a single-pass regex (`replace(/<\/?verified_facts>/gi,'')`), which a split-token payload defeats: `</veri</verified_facts>fied_facts>` reconstructs `</verified_facts>` after one removal (empirically confirmed by the reviewer), and attribute/self-closing variants (`<verified_facts x>`) slip past entirely. Both let attacker-published fact content escape the data fence in the optional prose summary. Fix: strip ALL angle brackets from fact content — no `<` means no tag of any form can be reconstructed. Fidelity loss is confined to the opt-in prose; the authoritative facts/citations are untouched. New unit test drives the split-token + attribute payload and asserts the fenced content contains no angle brackets and exactly one structural open/close delimiter. (The same review verified the round-2 count()/vm-scoping fix, the embedder fallthrough, and the adversarial network test are all correct.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(round-4 review — MEDIUM)
A fresh-eyes convergence sweep found a real defect that four prior review passes
missed. dragAnswerLocal validates the contextGraphId with validateContextGraphId
before any SPARQL (the charset gate rejects < > { } space \ "). The two NEW
cross-node entry points did NOT:
- dragAnswerNetwork passed the raw id straight to getContextGraphOnChainId, which
interpolates it UNescaped into a SPARQL IRI (`<did:dkg:context-graph:${id}> …`).
A `>` closes the IRI and injects SPARQL into the local oxigraph store.
- the PROTOCOL_DRAG_ANSWER libp2p handler calls isContextGraphPublicOnChain
(→ same sink) BEFORE the public-only gate and before dragAnswerLocal's own
validation — so an UNAUTHENTICATED remote peer triggers the injected query even
for a non-public/garbage id (per-peer rate-limited, but it still executes).
Blast radius is read-only (store.query only; no UPDATE path; WASM oxigraph has no
SERVICE/federation) → blind cross-graph read disclosure + expensive-query DoS,
hence MEDIUM. Fix: validate the id at both new entry points before the sink,
mirroring the local path. New unit test asserts a crafted injection id is rejected
and never reaches getContextGraphOnChainId/findNodesServingCG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th (round-5 review — MEDIUM) Round-4 validated dragAnswerNetwork + the wire handler, but a fresh sink-completeness sweep found one more: the index-warm path. VectorEntityRetriever.ensureIndexed() interpolates the contextGraphId into a SPARQL string literal (vmPrefix), and warm() is invoked from emitMemoryGraphChanged on memory-change events — including KC_PUBLISHED from an UNAUTHENTICATED remote PROTOCOL_PUBLISH, whose contextGraphId is never validated upstream (the publisher's own write escapes the graph IRI, stripping the quote, so the publish succeeds and the raw id still reaches the dRAG read). A `"` in the id closes the literal and injects SPARQL into the live store (read-only on the default oxigraph-server backend → blind cross-graph disclosure / query DoS). MEDIUM. Fix: validate at ensureIndexed — the single chokepoint EVERY indexing caller (retrieve + warm, and thus every emitMemoryGraphChanged listener) passes through — before the id reaches any SPARQL. Regression test asserts warm() with a quote-bearing id runs zero queries. With this, all four dRAG SPARQL entry points (local, network, wire, index) validate the id, matching the original local-path guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…regation (round-6 review — MEDIUM)
Found by a fresh-eyes sweep in the ORIGINAL P3 trustless aggregation (not the
productionization). Two facets, one root cause:
- The verdict cache keyed on `kaId|triple|leaf|chunkId` — OMITTING proof.siblings
(and content/leafCount/merkleRoot/seal), all of which the merkle verdict depends
on. So a malicious peer ordered before an honest one could return a citation with
the same (kaId, triple, leaf, chunkId) but GARBAGE siblings: it verifies false,
caches {verified:false} under the shared key, and the honest peer's good proof
then HITS that poisoned cache and is never re-verified — its fact is reported
unverified and the attacker's junk citation retained. The code's own comment
("a bad proof can't poison an honest one") was falsified by the key. Fix: key on
the FULL proof identity (JSON of kaId+triple+proof+onChain+seal).
- The maxCitations cap was filled first-come counting UNVERIFIED citations, and the
CG-scope gate only checks kaId-in-CG (not that the triple is a real leaf), so an
early peer's in-scope-but-unverifiable citations could starve honest verified
facts out of the slot budget. Fix: dedup per fact keeping the verified citation,
then fill the cap VERIFIED-FIRST.
Neither facet forges verified:true (merkle + live-chain re-anchor block that); the
damage was suppression/downgrade of honest facts. Two regression tests added (an
earlier bad-siblings peer no longer poisons the honest verdict; verified facts win
the cap over earlier unverified junk). Full agent suite green (157 files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…OT-RFC-55)
Adds the natural completion of dRAG: after retrieving + verifying facts, run the
EYE N3 reasoner (eyereasoner, in-process WASM, optional dep) over the VERIFIED
facts to DERIVE proof-carrying conclusions — negation, transitive inference, and
policy logic that vectors/SPARQL can't, with an auditable derivation an LLM can't
give you.
- `DragReasoner` (drag-reasoner.ts): serialize verified facts → N3 (canonical, via
tripleContentV10), run EYE (output:'derivations'), and reconstruct each
conclusion's proof by matching the rule-body facts back to their citations
(hash-exact, from the input set — leaves are never fabricated).
- `gatherVerifiedFacts` (agent): enumerates the CG's VM KAs and cites EVERY
canonical triple (relationship + attribute, unlike the literal-only answer
path), filtered to checks.verified — the trust gate (only proven facts reach EYE).
- Route: `reason:true` on /api/answer (local scope) → result.reasoning.derived,
each { conclusion, rule, support[] }. Rules auto-discovered from VERIFIABLE
rule-KAs (predicate …/drag/reasoning#ruleN3) and/or request `rules` N3.
config.drag.reasoning toggle. Derived ≠ published (kept out of facts/citations).
- Demo (scripts/drag-reason-demo.mjs): a multi-agent code graph where EYE derives
D1 violatesReviewPolicy (negation: no senior review) while D2 is compliant, plus
transitive call-graph impact — every proof leaf a chain-verified citation.
Validated: unit (drag-reasoner.test.ts), devnet release gate (devnet/drag-reason,
test:devnet:drag-reason), and the live demo. Adversarial review (15 raised, 5
confirmed) verified the trust invariants HOLD (only-verified-to-EYE; derived can't
masquerade as published; no fabricated proof leaf; N3 literal-injection escaped).
Addressed: hard caps on facts/rules/derived; documented that EYE's in-process WASM
blocks the event loop (an in-process timeout can't interrupt it) so worker-thread
isolation is the planned hardening — disable reasoning on non-loopback/untrusted
deployments; and that the proof set is best-effort (sound verified leaves, not a
minimal proof — EYE justification output is the planned exact-proof path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upgrades the "dRAG Ask" panel with the EYE reasoning tier: - a "🧠 reason (EYE)" toggle (local scope) + an optional N3-rules textarea so you can apply your own rules on the fly; - a "Derived" section rendering each proof-carrying conclusion as a collapsible card — the conclusion, and its proof as a list of ✓ chain-verified support facts — with a "derived ≠ published" note (EYE reasons over only verified facts). api.ts threads `reason`/`rules` and types the `reasoning` response. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| processed++; | ||
| try { | ||
| // CG-scope binding — only credit KAs that belong to the asked CG. | ||
| if (askedCgId !== null && typeof this.chain.getKAContextGraphId === 'function') { |
There was a problem hiding this comment.
🔴 Bug: Network answers can accept citations from the wrong context graph when scope cannot be resolved
What's wrong
The network answer contract is scoped by contextGraphId, but this path skips KA-to-CG binding whenever the requester cannot resolve the CG id locally. That lets a malicious or mistaken peer return a real, chain-verifiable citation from a different context graph and have it included as a verified answer for the requested graph.
Example
A node asks scope: "network" for new-cg before its local ontology mapping is synced, so getContextGraphOnChainId('new-cg') returns null. A peer returns a cryptographically valid citation for a KA in other-cg; the citation is re-verified against the chain and included with stats.verified = 1 because the off-scope check is skipped. Expected behavior: no credited facts unless the KA is proven to belong to new-cg.
Suggested direction
Require CG scope enforcement before crediting network citations. If the asker cannot resolve the requested CG id, return an error or no citations instead of counting provenance-unconfirmed facts as verified.
For Agents
In dragAnswerNetwork, fail closed when the requested CG on-chain id cannot be resolved or when getKAContextGraphId is unavailable. Preserve remote citation re-verification, but only add facts after KA-to-requested-CG binding succeeds. Add a test where getContextGraphOnChainId returns null and a remote peer supplies a valid off-scope citation; the result should contain no verified facts.
There was a problem hiding this comment.
🔴 Bug: Network answers fail open when the requested CG id cannot be resolved
What's wrong
The code only enforces the cross-CG scope binding when the asker can resolve the requested context graph's on-chain id. If that lookup fails or is stale, it still accepts and returns chain-valid citations without proving they belong to the requested CG, which breaks the endpoint contract that the answer is scoped to contextGraphId.
Example
Node A asks scope: network for contextGraphId: supply-cg with an explicit serving peer, but its ontology sync has not resolved supply-cg yet. A peer returns a valid citation from other-cg. Because askedCgId is null, the scope check is skipped, the citation is re-verified cryptographically, and the answer can include facts from the wrong CG.
Suggested direction
Treat unresolved askedCgId as a hard error for scope: network, or at minimum do not count or return citations as verified for the requested context graph until KA scope can be checked.
For Agents
Look at dragAnswerNetwork around askedCgId resolution and the KA scope check. Preserve trustless verification, but fail closed or return no citations when the requested CG cannot be resolved or when getKAContextGraphId is unavailable. Add a network aggregation test where getContextGraphOnChainId returns null and a peer offers an otherwise valid off-scope citation.
| const payment = parseXPaymentHeader(opts.xPaymentHeader); | ||
| if (!payment) return { kind: 'challenge', required }; | ||
| // Verify against the challenge but honour the payer's echoed nonce. | ||
| const receipt = await opts.verifier.verify(payment, { ...required, nonce: payment.nonce || required.nonce }); |
There was a problem hiding this comment.
🔴 Bug: Paid answer challenges are replayable because the server does not enforce its nonce
What's wrong
The payment challenge includes a nonce, but verification honors whatever nonce the payer sends. That removes the replay protection the nonce is meant to provide, so one accepted payment header can be reused for later priced answers with the same amount/network/asset/payTo.
Example
Client gets a 402 challenge for question A with nonce n1, pays, and receives an answer. The same X-PAYMENT header with nonce n1 can then be reused for question B: the route creates a new nonce n2, but resolvePayment replaces it with n1 before verification, so the mock verifier accepts and returns another paid receipt. Expected behavior: stale or mismatched challenge nonces should be rejected or settled idempotently against a stored challenge/payment record.
Suggested direction
Keep the challenge nonce authoritative. Either persist/derive nonce state so the retry can validate the original nonce, or make the payment payload bind to a deterministic request-specific challenge and reject mismatches/replays.
For Agents
In packages/cli/src/daemon/payment.ts, bind PaymentPayload.nonce to the server-issued challenge nonce instead of replacing the required nonce with the payer's value. Update MockPaymentVerifier to reject nonce mismatches, and add a route/payment test proving a reused X-PAYMENT header from an earlier challenge is rejected.
There was a problem hiding this comment.
🟡 Issue: Payment verification accepts the payer's nonce as the required nonce
What's wrong
The server-generated nonce is meant to bind a payment to a specific challenge, but the code substitutes the client-supplied nonce into the requirement before verification. That removes replay protection from the mock x402 gate and would make future verifier implementations easy to wire incorrectly.
Example
A client obtains one X-PAYMENT payload for 0.01 USDC and then reuses the same header on a later priced /api/answer request. The route creates a new nonce, but resolvePayment passes the old payload nonce as the required nonce, so the verifier has no chance to reject the replay.
Suggested direction
Do not overwrite the server-generated required.nonce with payment.nonce; validate they match and track nonce use, or delegate replay prevention to a real facilitator while keeping the server-side challenge binding explicit.
Confidence note
This is gated behind config.drag.payments.enabled plus experimentalOverrides, but it affects the payment flow added in this PR when that path is enabled.
For Agents
Look at resolvePayment and the route's challenge creation. Preserve the 402 -> retry flow, but bind the presented payment to the server challenge nonce, either with a short-lived nonce store or a verifier contract that receives the original required nonce and checks/replays it. Add a test that reuses an old X-PAYMENT against a new challenge and expects another 402.
| bySubject.set(f.subject, arr); | ||
| } | ||
|
|
||
| const lines: string[] = []; |
There was a problem hiding this comment.
🟡 Issue: Keep presentation rendering out of the agent answer model
What's wrong
The agent layer now owns transport/UI formatting even though it already returns structured facts, citations, and per-node data. That makes the API harder to evolve because consumers have to know the agent's Markdown layout instead of relying on typed fields.
Example
If the agent renderer renames ## Sources to ## Citations, the structured citation cards still exist, but the UI prose extraction no longer strips the sources block and the panel renders duplicate source information.
Suggested direction
Make the agent return structured domain data plus a plain prose summary, and let MCP/UI/CLI render their own Markdown from facts/citations/per-node data.
For Agents
Look at packages/agent/src/dkg-agent-drag.ts renderAnswer/renderNetworkAnswer and packages/node-ui/src/ui/views/DragAskView.tsx. Preserve the facts/citations/stats payload, but move Markdown rendering to adapter layers or return a structured answerSummary/sections model. Add a focused UI/client test proving consumers do not parse sentinel markdown headings.
There was a problem hiding this comment.
🟡 Issue: Keep Markdown presentation out of the agent result contract
What's wrong
This pushes UI presentation into the agent layer and then forces the UI to recover structure by string parsing. That makes a typed API feel untyped at the exact boundary where the PR otherwise adds good structured fields, and it couples clients to incidental Markdown headings instead of the citation model.
Example
A future copy tweak from ## Sources to ## Citations, or a synthesized answer that legitimately contains ## Sources, changes UI behavior even though facts, citations, and perNode are already structured in the response.
Suggested direction
Make the agent/domain layer produce structured answer data only, with answer reserved for prose if needed. Put renderAnswer/renderNetworkAnswer behind client/UI renderers or a separate presentation helper, and remove heading-based parsing from the UI.
For Agents
In packages/agent/src/dkg-agent-drag.ts, preserve the API fields and citation behavior but stop embedding Sources/Network presentation into answer. Return structured sources/network data plus a plain digest/summary, then move Markdown rendering to UI/MCP/client helpers. Add/adjust a route or UI test proving citations render from structured fields without parsing section headings.
| return JSON.parse(JSON.stringify(dragMetrics)); | ||
| } | ||
|
|
||
| // Cache retrievers by embedder model so a model (esp. the local one) loads once. |
There was a problem hiding this comment.
🟡 Issue: Make retriever state per-call and context-owned instead of global mutable state
What's wrong
The route introduces hidden process-wide state for a dependency that is actually tied to a specific vector store and triple store, while the retriever interface exposes availability through a mutable side channel. This is a brittle boundary: callers must reason about object reuse and call ordering to understand a single answer.
Example
Two daemon contexts in the same process that use the same model string can share the same cached retriever even though they have different vectorStore or agent.store instances. Concurrent semantic requests can also overwrite degraded while another request is still deciding whether an empty result means no matches or no model.
Suggested direction
Return retrieval status explicitly from retrieve() and keep retriever instances owned by the daemon context that created their store dependencies.
For Agents
Update EntityRetriever.retrieve to return a result object such as { anchors, degraded } or throw a typed unavailable error. Move retriever caching/ownership into daemon lifecycle or request context keyed by the actual vector store, triple store, and provider config. Preserve semantic/keyword behavior and add a test with two retrievers or overlapping calls proving status is per-call.
| * pipeline), and as the deterministic CONTRAST BASELINE against which a real | ||
| * semantic model's recall is measured. | ||
| */ | ||
| export class HashingEmbeddingProvider implements EmbeddingProvider { |
There was a problem hiding this comment.
🟡 Issue: Do not grow dRAG provider implementations inside the vector-store primitive
What's wrong
The PR turns a general SQLite vector-store module into a mixed storage plus embedding-provider module. That makes a core utility absorb dRAG/model-specific concerns and increases future churn in the wrong file.
Example
Adding another embedding backend would require editing the low-level SQLite vector-store module even though the storage/search primitive does not need to know anything about model providers.
Suggested direction
Keep VectorStore as a storage/search abstraction and put model provider implementations behind a separate provider module owned by daemon configuration.
For Agents
Move OpenAIEmbeddingProvider, HashingEmbeddingProvider, and LocalEmbeddingProvider into a dedicated embedding provider module, leaving vector-store.ts focused on persistence/search plus the EmbeddingProvider interface if needed. Update imports in lifecycle, route modules, and tests; existing vector-store tests should still exercise only storage/search behavior.
| return encode({ error: `dRAG: invalid contextGraphId — ${idCheck.reason ?? 'rejected'}` }); | ||
| } | ||
| try { | ||
| const isPublic = await this.isContextGraphPublicOnChain( |
There was a problem hiding this comment.
🟡 Issue: Remote dRAG public-only gate is not verified
What's wrong
The new libp2p dRAG responder is unauthenticated and relies on this public-only gate, but the added tests only prove public fan-out and mocked aggregation. They do not prove that private or unregistered context graphs are denied before local answering runs.
Example
A regression that moves dragAnswerLocal before the isPublic check, or treats an on-chain lookup error as public, could leak an answer for contextGraphId: "private-cg" while the current tests still pass because they never invoke the remote handler denial path.
Suggested direction
Cover the responder ACL directly, including private/unregistered fail-closed cases, because this is the boundary that prevents unauthenticated peers from reading non-public context graphs.
For Agents
Add a unit test around the PROTOCOL_DRAG_ANSWER registered handler in packages/agent/src/dkg-agent-lifecycle.ts: stub isContextGraphPublicOnChain to return false or throw, send a valid JSON request, assert the decoded response is an error and dragAnswerLocal is not called. Keep the existing public-path behavior covered separately.
| // payments AND experimental overrides are enabled. ── | ||
| let settlement: SettlementReceipt | undefined; | ||
| const priceStr = | ||
| experimental && paymentsEnabled && typeof parsed.simulatePrice === 'string' |
There was a problem hiding this comment.
🟡 Issue: Paid /api/answer flow is not verified at the route boundary
What's wrong
The tests verify the pure payment helper and the payments-off default, but not the actual /api/answer behavior when payments are enabled. The route contains additional logic that can break independently of resolvePayment.
Example
A route regression such as dropping paymentsEnabled from the condition, reading the wrong X-PAYMENT header, or forgetting to attach { settlement } after a paid retry would not be caught by the current helper-only tests.
Suggested direction
Keep the existing payment helper tests, but add endpoint-level coverage for the config gates and response shaping around them.
For Agents
Add route-level tests for handleDragRoutes or an HTTP-level daemon harness: with { drag: { payments: { enabled: true }, experimentalOverrides: true } }, assert priced /api/answer returns 402 without X-PAYMENT, returns 200 with a valid encoded payment, includes settlement, and still leaves the disabled default path free.
There was a problem hiding this comment.
🟡 Issue: The x402 payment behavior is only tested below the route that implements it
What's wrong
The changed user-facing payment contract lives in the dRAG HTTP route, but the added tests stop at resolvePayment and MockPaymentVerifier. This leaves the config gates, simulatePrice handling, HTTP 402 response, retry header plumbing, and final settlement response unverified.
Example
A regression that makes priceStr always undefined, removes the jsonResponse(res, 402, ...) branch, or drops settlement from the final JSON response would still pass the current payment tests.
Suggested direction
Cover the /api/answer payment flow itself, not just the payment helper functions.
For Agents
Add a focused route test around packages/cli/src/daemon/routes/drag.ts: create a minimal RequestContext with drag.payments.enabled=true and experimentalOverrides=true, call POST /api/answer with simulatePrice, assert the first response is 402 with an x402 accepts challenge, retry with X-PAYMENT, and assert 200 includes settlement while preserving the answer shape.
| const maxTriples = Math.max(1, Math.min(opts?.maxTriples ?? 10000, 100000)); | ||
|
|
||
| const result = await this.store | ||
| .query(`SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } FILTER(STRSTARTS(STR(?g), "${vmPrefix}")) } LIMIT ${cap}`) |
There was a problem hiding this comment.
🔴 Bug: Reasoning can derive false closed-world conclusions from a silently truncated CG
What's wrong
The reasoning layer claims it reasons over the complete verified fact set, but the new gather path limits the graph list before invoking EYE and does not report that truncation. Any rule using absence, such as collectAllIn ... length 0, can become wrong when the omitted KAs contain the missing evidence.
Example
A CG has 201 verifiable-memory KAs. The first KAs contain D1 changes validateToken, validateToken inModule authModule, and authModule securityCritical true; the 201st KA contains the senior review for D1. With the default cap, gatherVerifiedFacts omits that senior review and a negation-as-failure rule can derive D1 violatesReviewPolicy true even though the complete CG says it should not.
Suggested direction
Either require a complete fact gather for reasoning, return a truncation note and skip negation-as-failure rules, or carry a truncated flag through ReasoningResult so callers cannot treat the derivation as sound.
For Agents
Look at gatherVerifiedFacts and the /api/answer reasoning path. Preserve the compute bounds, but make truncation explicit and prevent closed-world/NAF conclusions from being returned as authoritative when the fact set is incomplete. Add a test where the disconfirming fact is beyond reasoningMaxKas and prove the response warns/fails instead of deriving the violation.
| } catch { | ||
| recovered = ''; | ||
| } | ||
| authorSig = |
There was a problem hiding this comment.
🔴 Bug: Author-seal verification does not bind the seal to the cited KA
What's wrong
The EIP-712 author seal includes reservedKaId, but the verifier ignores it. That lets a seal from another KA be reported as a valid author signature for this citation as long as the signer and merkle root match, overstating the citation's author proof.
Example
An author has a valid seal for KA 1 with merkle root R. A citation for KA 2 with the same root R and same on-chain author can attach KA 1's seal. recoverCitationAuthor returns the expected author and the root matches, so authorSig becomes true even though the EIP-712 seal was not issued for KA 2.
Suggested direction
Include the reserved KA id in the author-signature verdict, and prefer loading the seal for the specific KA rather than by merkle root alone.
For Agents
Update citation production and verification in packages/agent/src/drag/citation.ts to require BigInt(seal.reservedKaId) === BigInt(kaId). Add a test that copies a valid seal to a citation with a different kaId but same root/author and expects authorSig: false and verified: false if a seal is present.
| return; | ||
| } | ||
|
|
||
| if (req.method === 'POST' && path === '/api/answer') { |
There was a problem hiding this comment.
🟡 Issue: Split the dRAG route before it becomes the feature switchboard
What's wrong
handleDragRoutes is accumulating unrelated orchestration concerns in a single POST branch. The implementation works as a first pass, but structurally it creates the exact spaghetti growth pattern this endpoint is likely to suffer from as dRAG gains more retrieval, payment, and reasoning modes.
Example
Adding another answer mode now means editing the same HTTP handler in the payment block, retrieval block, answer block, post-processing block, and metrics block. The route already has several feature flags (scope, retrieval, embedder, synthesize, reason, simulatePrice) competing in one function.
Suggested direction
Make the HTTP route mostly parse, delegate, and serialize. Move payment resolution, retrieval strategy resolution, post-processing, reasoning, and metrics into named functions or a dedicated service so each new mode has one canonical home.
For Agents
In packages/cli/src/daemon/routes/drag.ts, keep request/response behavior stable but extract a typed request parser plus a DragAnswerService/use-case that composes payment, retriever resolution, answer execution, synthesis, reasoning, and metrics through small helpers. Route tests should still cover 402, keyword/semantic, synthesis, and reasoning responses.
| * construction. When set, `dragAnswerLocal` uses embed→ANN→anchor→graph-expand | ||
| * instead of keyword retrieval. Absent ⇒ keyword fallback. | ||
| */ | ||
| entityRetriever?: EntityRetriever; |
There was a problem hiding this comment.
🟡 Issue: Use one explicit retriever ownership boundary
What's wrong
The daemon-owned semantic retriever leaks into the core agent as optional mutable state, while explicit semantic requests use a second global cache. That obscures the dependency boundary and makes retriever lifetime/configuration harder to reason about than it needs to be.
Example
There are now two ownership paths for the same dependency: the lifecycle-attached retriever for default/network serving, and a route-global cache for explicit semantic requests. A maintainer has to know which path applies before they can reason about retriever lifetime, store ownership, and model configuration.
Suggested direction
Avoid a mutable optional dependency on the base agent plus a separate route-level singleton cache. Put retriever lifetime behind a dedicated dRAG service or an explicit retrieval-strategy parameter so the daemon remains the owner of vector-store/embedder state.
Confidence note
This is a maintainability concern rather than proof of a current behavioral failure; the risk grows if more daemon instances, tests, or retriever types share one process.
For Agents
Review DKGAgentBase.entityRetriever, attachEntityRetriever, and retrieverCache. Prefer one ownership model: either construct a dRAG service in the daemon with a retriever factory, or pass a retrieval strategy explicitly into answer execution. Preserve keyword fallback and network serving behavior; add a focused test that two distinct retriever configurations do not share stale store/provider state.
| } | ||
|
|
||
| /** Split an N3 rules block into individual `{...} => {...} .` clauses. */ | ||
| function splitRules(rulesN3: string): string[] { |
There was a problem hiding this comment.
🟡 Issue: Do not model N3 rule attribution with regex string matching
What's wrong
The reasoner correctly delegates derivation to EYE, but then reconstructs rule attribution and support using ad-hoc regexes over N3 source text. That is a magical boundary in a proof-carrying feature and makes the implementation harder to extend or audit than a typed rule model would be.
Example
A rule body/head that uses the same local predicate name under different prefixes, nested braces, comments, or a predicate local name that appears in another token still flows through the same string heuristics. Even if behavior is acceptable for the demo rules, the implementation makes the reasoning proof boundary depend on incidental text shape rather than a parsed rule model.
Suggested direction
Use a real rule/proof representation: preferably EYE justifications, or at least parse rules into explicit head predicate and body predicate terms once. Then proof support can operate over typed terms instead of local-name substring heuristics.
For Agents
In packages/cli/src/daemon/drag-reasoner.ts, preserve current reasoning output but replace regex/string matching with either EYE justification output or a small parsed rule representation built by an N3/RDF parser. Tests should cover multiple prefixes with the same local name and rules containing nested/log constructs while preserving existing derivations.
feat: Decentralized GraphRAG (dRAG) V1 — verifiable, cross-node, monetizable answering (OT-RFC-55)
Implements OT-RFC-55 as a working V1 on
main(v10.0.0): ask one natural-languagequestion and get a grounded answer whose every fact is independently auditable
against the chain — answered locally, or fanned out across the nodes serving a
public Context Graph and re-verified by the asker, with an x402 payment seam
wired for monetization.
Built in four layered, independently-shippable increments. Each was validated on a
live 4-node devnet.
What's in it
P1 — Verifiable citation core (
core/crypto/citation.ts,agent/drag/citation.ts)Composes the already-shipped V10 primitives into one auditable citation object
{UAL, node, author-sig, merkle, on-chain}:buildV10ProofMaterial),re-anchored to the live on-chain
getLatestMerkleRoot(kaId)— so a citation thatverifies offline verifies on-chain by construction.
tripleContentV10).recoverCitationAuthor), with the chain-verifiedon-chain author as the authoritative fallback when the
_metaseal is absent.P2 —
dkg_answersingle-node (agent/dkg-agent-drag.ts,cli/routes/drag.ts,mcp-dkg)question → keyword retrieval over per-KA verifiable-memory graphs → canonical triples → a citation per fact. No node-side LLM required (keyword/structuralbaseline; LLM synthesis is a future enhancement). New
POST /api/answer,DkgClient.answer(), and thedkg_answerMCP tool.P3 — Cross-node fan-out (
scope:"network")findNodesServingCGreads thecontextGraphsServedphonebook (the §5.1context-oracle index); a new
PROTOCOL_DRAG_ANSWERlibp2p protocol lets a peeranswer over a public CG (ACL = the same
isContextGraphPublicOnChainfail-closed gate as query-remote).
dragAnswerNetworkfans out, dedups, and —crucially — re-verifies every citation against the asker's own chain, so the
asker trusts no serving node's self-reported verdict and can answer over knowledge
it does not hold. Optional explicit
peersoverride for not-yet-gossiped CGs.P4 — x402 payment seam (
cli/daemon/payment.ts)Public CGs are free in V1, but the HTTP-402 challenge +
X-PAYMENTwire formatand a pluggable
PaymentVerifier(MockPaymentVerifierfor dev/CI) are in place sothe real Coinbase/USDC facilitator drops in behind one interface.
simulatePriceexercises the full
402 → pay → 200 + receiptflow. Localized in the drag route —does not touch the central request chain.
Validation
green for every touched package (core 1092, mcp-dkg 325, agent ~1740, cli ~2120).
author-sig ✓ merkle ✓ on-chain ✓;X-PAYMENT, paid answer still fully verified;remote serving nodes, every citation re-verified against its own chain.
Security hardening (adversarial review)
A multi-dimension adversarial review of the diff confirmed 10 findings (3 high,
3 medium, 3 low), all addressed in the hardening commit:
PROTOCOL_DRAG_ANSWERverb is nowper-peer rate-limited and clamped to a small remote cost ceiling.
caller-driven reflection); per-peer citation re-verification is bounded.
citation is guarded, so one bad/version-skewed peer can't fail the whole answer.
the asked context graph (
getKAContextGraphId); a peer can't pass off averifiable fact from a different KA, and the asker never trusts the remote's
scope fields.
prefers a verified citation, so a bad proof can't poison/suppress an honest one.
validateContextGraphIdbefore SPARQL interpolation;the leaf count is re-anchored against chain (the pure check was tautological).
The two refuted findings (a "second-order IRI injection" and a "dedup delimiter
collision") were verified non-exploitable (the store's serialization boundary and
the terminal-field key layout, respectively).
Reasoned, not unit-tested (validated by code review + the live happy path, but
without a dedicated adversarial test): the off-scope citation drop (a malicious
peer returning a fact from a different KA — the CG-scope binding is confirmed
active on the non-subscriber path, since ontology id-mappings sync network-wide),
the verdict-cache collision fix, and the
dkg_answerMCP stdio tool path (theunderlying
/api/answerendpoint is live-validated). The CG-scope check failsopen (facts stay cryptographically verified, scope unconfirmed) only when the
asker cannot resolve the CG's on-chain id — surfaced explicitly in the answer.
Honest scope / deferred (NOT in V1)
facts; private-data answering and the ZK layers are future work.
advertises only public, subscribed CGs).
MockPaymentVerifieronly; no live facilitator / USDC(the node has no USDC and is ethers-only; not CI-runnable).
headline; LLM is a clean seam (no key on devnet). The caller names the CG.
contextGraphsServedadvertisements integrateinto peers' agents-CG on the profile-heartbeat cadence, so discovery can lag a
just-created CG; the explicit
peersoverride is the deterministic fast-path.How to try it
🤖 Generated with Claude Code
Production hardening (this update)
Following the V1 above, this update moves dRAG from "PR-stage" to "releasable" in six steps, each committed and kept behind a green release-gate suite:
devnet/drag-v1/(pnpm test:devnet:drag-v1): P1 client-side verify + tamper-rejection, P2 cited answer, P3 cross-node re-verify, P4 payments-off default, and the keyword↔semantic split. 7/7 on a live devnet.config.drag.*schema; the public answer param is nowretrieval: default|keyword|semantic. The rawembedder/simulatePriceknobs are gated behindconfig.drag.experimentalOverrides, keeping demo affordances out of the public contract.config.drag.payments.enabled(default false); a priced request is answered for free unless explicitly enabled.embedderBaseURL(no heavy dependency), offline MiniLM as the opt-in. Documented indocs/use-dkg/verifiable-answers-drag.md.sqlite-vec/pgvectorupgrade documented.GET /api/answer/metrics,stats.latencyMs, and aretrievalDegradedsignal that distinguishes "no embedding model" from "no matches". Optional grounded prose synthesis (synthesize:true) composes from only the verified facts and never mutatesfacts/citations— those stay the authoritative, machine-readable answer.Adversarial self-review
A multi-agent review of the productionization diff raised 20 findings (13 confirmed, one empirically reproduced); all addressed in
3664c70b7. Notably:embedderknob, making its retrieval dropdown a silent no-op. Migrated to the publicretrievalparam.verified:trueon a tampered triple, drops a genuinely-verifiable but off-scope citation (CG-scope-swap defense), and isolates a throwing peer.Test status
core1092 ·mcp325 ·cli2122 ·agent156 files · dRAG units (retriever / embedder / synthesize / citation / network-trustless) ·devnet/drag-v17/7 — all green.Known deferred: a live-daemon route-level synthesis test (the
synthesizeAnswerfunction is unit-tested); decentralized semantic routing over the public catalog (Phase 2); a real x402 facilitator + USDC settlement (the wire format + mock verifier are in place behind the off-by-default flag).