Skip to content

feat(dRAG): Decentralized GraphRAG V1 — verifiable, cross-node, monetizable answering (OT-RFC-55)#1314

Open
branarakic wants to merge 21 commits into
mainfrom
feat/drag-v1
Open

feat(dRAG): Decentralized GraphRAG V1 — verifiable, cross-node, monetizable answering (OT-RFC-55)#1314
branarakic wants to merge 21 commits into
mainfrom
feat/drag-v1

Conversation

@branarakic

@branarakic branarakic commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

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-language
question 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}:

  • Merkle inclusion over the keccak V10 structured tree (buildV10ProofMaterial),
    re-anchored to the live on-chain getLatestMerkleRoot(kaId) — so a citation that
    verifies offline verifies on-chain by construction.
  • Content-binding: the proof binds to the exact cited triple (tripleContentV10).
  • EIP-712 author-seal recovery (recoverCitationAuthor), with the chain-verified
    on-chain author as the authoritative fallback when the _meta seal is absent.

P2 — dkg_answer single-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/structural
baseline; LLM synthesis is a future enhancement). New POST /api/answer,
DkgClient.answer(), and the dkg_answer MCP tool.

P3 — Cross-node fan-out (scope:"network")
findNodesServingCG reads the contextGraphsServed phonebook (the §5.1
context-oracle index); a new PROTOCOL_DRAG_ANSWER libp2p protocol lets a peer
answer over a public CG (ACL = the same isContextGraphPublicOnChain
fail-closed gate as query-remote). dragAnswerNetwork fans 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 peers override 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-PAYMENT wire format
and a pluggable PaymentVerifier (MockPaymentVerifier for dev/CI) are in place so
the real Coinbase/USDC facilitator drops in behind one interface. simulatePrice
exercises the full 402 → pay → 200 + receipt flow. Localized in the drag route —
does not touch the central request chain.

Validation

  • Unit: 18 citation tests (core+agent) + 14 payment tests (cli). Full suites
    green for every touched package (core 1092, mcp-dkg 325, agent ~1740, cli ~2120).
  • Live devnet (4 nodes): combined run, 9/9 —
    • local cited answer, every citation author-sig ✓ merkle ✓ on-chain ✓;
    • priced request returns 402, then 200 + settlement receipt with a valid
      X-PAYMENT, paid answer still fully verified;
    • an asker holding no copy of the CG assembled a multi-fact answer from
      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:

  • Remote handler DoS — the unauthenticated PROTOCOL_DRAG_ANSWER verb is now
    per-peer rate-limited and clamped to a small remote cost ceiling.
  • Asker fan-out bounds — peer fan-out is capped + concurrency-bounded (no
    caller-driven reflection); per-peer citation re-verification is bounded.
  • Malformed-peer isolation — each peer response is shape-validated and each
    citation is guarded, so one bad/version-skewed peer can't fail the whole answer.
  • CG-scope binding — re-verification confirms each citation's KA belongs to
    the asked context graph (getKAContextGraphId); a peer can't pass off a
    verifiable fact from a different KA, and the asker never trusts the remote's
    scope fields.
  • Verdict integrity — the verdict cache is keyed on the proof and dedup
    prefers a verified citation, so a bad proof can't poison/suppress an honest one.
  • Defense-in-depthvalidateContextGraphId before 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_answer MCP stdio tool path (the
underlying /api/answer endpoint is live-validated). The CG-scope check fails
open (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)

  • Confidential / ZK answering (RFC §5.3) — citations are over public (VM)
    facts; private-data answering and the ZK layers are future work.
  • Private-CG routing — fan-out is public-CG only (the phonebook deliberately
    advertises only public, subscribed CGs).
  • Real x402 settlementMockPaymentVerifier only; no live facilitator / USDC
    (the node has no USDC and is ethers-only; not CI-runnable).
  • LLM synthesis + question→CG auto-routing — the keyword baseline is the V1
    headline; LLM is a clean seam (no key on devnet). The caller names the CG.
  • Phonebook propagation latencycontextGraphsServed advertisements integrate
    into peers' agents-CG on the profile-heartbeat cadence, so discovery can lag a
    just-created CG; the explicit peers override is the deterministic fast-path.

How to try it

./scripts/devnet.sh start 4
# create a public CG, publish a KA, then:
curl -s localhost:9201/api/answer -H "Authorization: Bearer $TOKEN" \
  -d '{"question":"...","contextGraphId":"<cg>","scope":"network"}'

🤖 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:

  1. Integration suitedevnet/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.
  2. Config + clean public API — a config.drag.* schema; the public answer param is now retrieval: default|keyword|semantic. The raw embedder/simulatePrice knobs are gated behind config.drag.experimentalOverrides, keeping demo affordances out of the public contract.
  3. x402 OFF by defaultconfig.drag.payments.enabled (default false); a priced request is answered for free unless explicitly enabled.
  4. Embedder story — config-driven: local Ollama / hosted OpenAI via embedderBaseURL (no heavy dependency), offline MiniLM as the opt-in. Documented in docs/use-dkg/verifiable-answers-drag.md.
  5. Indexing — incremental (a re-scan embeds only newly-published entities) + publish-time index warm. Brute-force cosine retained (fine to ~100k vectors/node) with the sqlite-vec/pgvector upgrade documented.
  6. Observability + optional synthesisGET /api/answer/metrics, stats.latencyMs, and a retrievalDegraded signal that distinguishes "no embedding model" from "no matches". Optional grounded prose synthesis (synthesize:true) composes from only the verified facts and never mutates facts/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:

  • HIGH — the incremental-index freshness gate compared distinct subjects to distinct (graph, subject) pairs, so once a subject recurred across KAs the gate stayed satisfied and freshly-published entities were silently never embedded. Fixed to count pairs; regression test reproduces it.
  • HIGH — the node-UI panel still sent the now-gated embedder knob, making its retrieval dropdown a silent no-op. Migrated to the public retrieval param.
  • HIGH (test) — a new adversarial unit test proves the P3 trustless property: the asker rejects a lying peer's verified:true on a tampered triple, drops a genuinely-verifiable but off-scope citation (CG-scope-swap defense), and isolates a throwing peer.

Test status

core 1092 · mcp 325 · cli 2122 · agent 156 files · dRAG units (retriever / embedder / synthesize / citation / network-trustless) · devnet/drag-v1 7/7 — all green.

Known deferred: a live-daemon route-level synthesis test (the synthesizeAnswer function 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).

Branimir Rakic and others added 21 commits June 24, 2026 02:49
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>
@branarakic branarakic marked this pull request as ready for review June 30, 2026 15:25
processed++;
try {
// CG-scope binding — only credit KAs that belong to the asked CG.
if (askedCgId !== null && typeof this.chain.getKAContextGraphId === 'function') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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[] = [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants