Skip to content

Add buzz-dkg integration to the registry - #21

Merged
Jurij89 merged 4 commits into
OriginTrail:mainfrom
Zigoljube:add-buzz-dkg
Jul 28, 2026
Merged

Add buzz-dkg integration to the registry#21
Jurij89 merged 4 commits into
OriginTrail:mainfrom
Zigoljube:add-buzz-dkg

Conversation

@Zigoljube

@Zigoljube Zigoljube commented Jul 27, 2026

Copy link
Copy Markdown

What this PR does

Adds buzz-dkg — an integration that joins a Block Buzz (Nostr/NIP-29) channel as a member and turns pinned decision threads into DKG Knowledge Assets across Working, Shared, and Verifiable Memory, preserving a PROV-O provenance chain to the signed source events. It also answers questions in-channel using deterministic, no-model retrieval scoped to the room's Context Graph (claims cited, unsupported questions refused).

Integration links

Scope & faithfulness

  • Integration uses only the supported public interfaces (HTTP API, dkg CLI, MCP). It does not import internal DKG packages, patch node source, or write to SPARQL directly. (The integration's design principle is "no forks, no patches, on either side"; the DKG is used only through the edge node's public HTTP surface.)
  • memoryLayers correctly reflects which layer(s) the integration touches (WM, SWM, VM).
  • v10PrimitivesUsed correctly reflects which primitives are exercised.
  • Terminology matches the v10 vocabulary (Context Graph, Assertion, Knowledge Asset, Curator, Entity, WM/SWM/VM).

Security declarations (Section 8a)

  • security.networkEgress lists every external host (the operator-configured Buzz relay; the local DKG node on 127.0.0.1 does not count).
  • security.writeAuthority lists every DKG write operation; the Curator-authority op (vm/publish — PUBLISH to Verifiable Memory) is called out explicitly.
  • security.credentialsHandled lists the one third-party credential (the Buzz service Nostr key).
  • No preinstall/install/postinstall scripts. (Install kind is manual — no published package; the source repo has no install scripts.)
  • Build provenance — n/a (kind manual, no published package).
  • The pinned git SHA is the exact public commit the entry represents. (No built package; SHA is the pinned public repo state.)

Contributor attestation

  • This integration is my own work or properly licensed (Apache-2.0).
  • It contains no intentional backdoors, malicious logic, or data-exfiltration paths beyond what is declared in security.networkEgress.
  • I understand that the integration may be delisted for any material misrepresentation in the registry entry.
  • I commit to a minimum 6-month maintenance window post-acceptance.

Notes for the committee

  • Scope (VM publish) justification. The validator warns that writeAuthority declares a Verifiable-Memory publish (Curator authority), which is beyond a WM/SWM-only Round 1. The WM→SWM capture-and-receipt path is the integration's floor and runs on every pinned decision; Verifiable-Memory publish is opt-in and disabled by default (publishMode: 'disabled'), and when enabled is triple-gated: the node must report chain base:8453, a rolling-24h publication budget applies, and each publish additionally requires a per-channel authorized promoter's approval reaction, consumed exactly once and validated against the receipt's KA + source-set digest. So the integration fully satisfies the WM/SWM scope while extending — only under explicit human attestation — to Verifiable Memory.
  • Install kind manual. The integration is a standalone daemon (not yet an npm/docker package) that an operator runs against their own Buzz relay and DKG v10 edge node, binding a channel to a Context Graph. Setup, env, and the channel↔Context-Graph binding are documented in README.md + docs/DESIGN.md. Happy to publish a container image and resubmit as kind: service if the committee prefers.
  • Provenance. Every Verifiable-Memory Knowledge Asset preserves an unbroken wasDerivedFrom chain to its signed Nostr source events (Schnorr-verifiable independently of the relay). A live example produced by this integration is published at UAL did:dkg:base:8453/0x633e5a7c5e612d9981538f60d824cc03be97e2ab/2201 (tx 0x6daf3e0bad8cba13f7508f69c5550750a30294e997bf5e7fdffe9a24170dbb38, block 49145748) in the FIFA World Cup 2026 Context Graph.

Review history

@Jurij89

Jurij89 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review

I reviewed this from several angles — registry-policy compliance, technical correctness, security, the grounded-answering path, setup and in-channel UX, documentation, and repo quality. I read the pinned commit c42efd2 rather than only the manifest, cross-checked every DKG call against the node source at 10.0.8, executed the real modules to measure behaviour, and mutation-tested the security guards.

Verdict: strong submission, clear changes needed before merge. The engineering is well above the bar for a community entry, and the evidence trail (gate reports, transcripts, spend accounting) made this reviewable in a way most submissions aren't. Nothing below is a scope violation or a trust problem — the security declarations are honest and, where I could check them, conservative rather than inflated.

Baseline I reproduced: registry validator passes (3 entries, 0 errors, 1 expected VM-scope warning). In the integration repo, npm install --ignore-scriptstypechecklintbuildtest are all clean (51 tests, 6 files). No pre/post-install scripts. No secret key material anywhere in the repo or its full git history — I checked the committed DB byte-level, and every 64-hex string in it resolves to a public Nostr event id, pubkey, or digest.


Blocking — the entry itself

1. license: "Apache-2.0" is declared, but there is no LICENSE file.
schema/integration.schema.json:99 defines the field as "SPDX license identifier (must match the LICENSE file in the pinned commit)". At c42efd2, git ls-files | grep -i licen returns nothing; no source file carries a header; the README has no license section; package.json is "private": true. A package.json string is not a license grant, and Apache-2.0 §4(a) requires recipients get a copy of the License — for kind: manual, where operators are told to clone and run the source, they currently receive code with no license text. Note this isn't a stale-pin artifact: the repo's current main is the same commit, so it needs a new commit, not just a re-pin. Registry CI can't catch it either — security-checks.mjs performs no package checks when install.kind is manual.

2. The headline evidence claim can't be verified at the commit it pins.
promotionPath cites UAL …/2209 and "Tactical read on France 2-0 Morocco". At c42efd2: git grep 2209 → nothing; git grep -i "morocco\|france" → nothing. Every artifact documents a single mainnet publication at …/2201 (README.md:18, GATE_D3_REPORT.md:9, WORKLOG.md:77), and the FIFA run is recorded as SWM-only with content "Argentina 2-0 Austria" (WORKLOG.md:84) — the committed demo DB corroborates that (ops row state=receipted, ual=NULL). The PR body cites 2209 with no tx hash, block, or date either, so there's no substantiation in the repo or the submission, while every other on-chain action here carries a full gate report.

I assume this is a genuine post-pin publish rather than an error — but it's the entry's only externally checkable claim, and it's the one a reviewer spot-checks first.
→ Either cite …/2201 (fully evidenced: tx 0x6daf3e0b…, block 49145748) or push the 2209 trail and re-pin.

3. requiresDkgNodeVersion: ">=10.0.0" is provably too loose. (PR #20 declared >=10.0.8.)
The graph-scoped publish path that produces the advertised did:dkg:base:8453/0x<author>/<n> UAL form landed in 10.0.7 — I checked the tags directly: resolveGraphScopedPublishDescriptor has 0 hits in packages/publisher/src/dkg-publisher.ts at v10.0.0/10.0.4/10.0.6 and 5 hits at v10.0.7/v10.0.8. Before that the publisher returns a different UAL scheme (<storageAddr>/<packed kaId>) from the reservedUal the recovery path posts into the channel. Every endpoint exists at 10.0.0, so operators get no fast failure — just wrong UALs. The repo's own record never goes below 10.0.8 (SPEC.md:108, INTERFACES.md:43, acceptance-transcript.md:15 shows 10.0.9).
→ Declare >=10.0.8.

4. description overclaims: "neuro-symbolic retrieval" describes something that isn't there.
There is no neural component at all — retrieval is SPARQL CONTAINS substring matching and the answer is literally best.description.trim() + " [1]" (src/ask/grounded.ts:175). The repo is honest about this ("no model required", README.md:44; "Deterministic no-model answering", grounded.ts:142) — only the manifest overclaims, in a field the schema says should be plain prose without marketing. fitNotes carries the same clause.
→ Reword to match the README. The framing the code actually supports ("a model provider can replace only the composition step") is fine to state explicitly.

5. Please close PR #20 as part of merging this.
#20 adds integrations/buzz-dkg-integration.json — different slug and different filename, so validate.mjs slug-uniqueness will not catch a double merge. The registry would list one integration twice, pinned to two commits with different security posture. buzz-dkg is the better slug.


Blocking — one real defect

6. One @dkg ask message can permanently brick the daemon.
triggers/detect.ts:50-69 accepts ask from any pubkey (only self-triggering is excluded) with no length bound, and grounded.ts:69-76 builds every unordered term pair — O(n²) — with no cap. Measured by running the real module against a stub client:

chat message terms largest SPARQL total built
normal question 5 1.1 KiB negligible
~4 KB of unique words ~400 ~7.5 MiB ~30 MiB
~9.7 KB of unique words 1200 59 MiB 118 MiB

The node's MAX_BODY_BYTES is 10 MiB (packages/cli/src/daemon/http-utils.ts:1267), which splits this in two. Just under the limit, the node genuinely accepts and evaluates a ~10 MiB filter with ~250k CONTAINS clauses, twice per ask — on the codebase with a documented history of store CPU pressure on unbound scans. Above it the request 413s, but the daemon has already materialised the string; a separate run reproduced a hard V8 heap OOM at ~8,000 words.

What makes this a blocker rather than a nuisance is that it is self-perpetuating. claimAsk inserts the row as 'pending' before any work (daemon.ts:458), and executeAsk has no try/catch — it calls resolveAsk only on the two success paths. So the OOM (or any throw) leaves the row pending forever. On the next start, recover() runs before catchUp/subscribe (daemon.ts:100), re-fetches the event, and re-runs executeAsk(..., isRecovery=true) — which skips the claimAsk guard (:458). The poisoned question is replayed at every boot and kills the process again. The daemon can never start until an operator hand-edits SQLite.

The same shape, minus the OOM, means any ask that errors — a node 5xx, a malformed subject reaching the <${e.rootUri}> interpolation at grounded.ts:122 — accumulates a pending row replayed on every restart. Note the ops recovery loop immediately above does have a .catch() (:140-142), so this is an asymmetry rather than a choice.
→ Cap the question (e.g. 512 chars) and the term set (terms.slice(0, 12) bounds pairs at 66); wrap executeAsk in try/catch and resolveAsk(...,'refused') on error; add the .catch() to the recovery call. A per-pubkey ask rate limit would be good defence-in-depth — the asks table already has channel_id and created_at.


Setup is currently broken

install.kind is manual, so the docs are the installer — these are merge-relevant.

7. The documented start sequence cannot work: nothing loads .env.
README.md:62-65 says cp .env.example .envnpm start, but start is node --experimental-strip-types src/index.ts with no --env-file, there is no dotenv dependency, and loadConfig reads only process.env. Reproduced verbatim → Error: missing required env BDI_DKG_TOKEN_PATH. The repo's own demo-fifa/run-daemon.sh:4 does set -a; source …; set +a, confirming the real workflow. node --env-file=.env … works (verified on v22.22).

8. There is no documented path from "I have a relay and a node" to a working bot.
The only runnable README section is the isolated dev stack (Docker + pnpm + Rust + postgres/redis/minio + a 6-node devnet) — not the deployment the entry's oneLiner advertises. Undocumented: generating BDI_SERVICE_KEY; getting the bot into a NIP-29 channel (the daemon only subscribes, never self-adds) and finding the service pubkey an admin must add (only emitted in the daemon started log); where a channelId comes from; obtaining a Context Graph and its real 0xCuratorAddress/name id form — the README example uses "contextGraphId": "devnet-test" while production form is 0x633E…/fifa-world-cup-2026; and where the DKG token comes from. Most of these fail silently: wrong channel membership → the daemon starts clean and simply never sees an event.

The fallback path is blocked too: phase0/README.md:25-28's .env.spike generator is a heredoc whose entire body is a comment — it exits 0 and creates nothing, so both README quickstarts stop at step 1.

9. Two defaults and a version floor are wrong.

  • .env.example:9 / config.ts:60 default BDI_DKG_API to :9420 — the author's devnet port. A stock node is :9200, which the repo's own INTERFACES.md states correctly. It's the value most likely left at its default and fails as ECONNREFUSED with no hint. (In fairness, the README's only quickstart is the isolated devnet, where 9420 is right — the default just doesn't match the advertised deployment.)
  • engines: ">=22.5" and README.md:49 understate the real floor: --experimental-strip-types is a Node 22.6 feature, so an operator on exactly the lowest blessed version can't start the daemon. (I could only test 22.22 here, so this rests on Node's release history.)
  • README.md:39-41 still documents publication as disabled | devnet only — it never mentions mainnet, which is what this pinned commit adds and what the entry's security narrative rests on, nor BDI_MAX_PUBLISHES_PER_DAY. The measured cost lives only in GATE_D3_REPORT.md:15. The one mode that spends real ETH and writes irreversibly to Base is undocumented at the URL the registry sends operators to.

Should-fix

10. The entry doesn't disclose that event authenticity is delegated to the relay.
No inbound Nostr event is signature-verified: relay/client.ts:208-210 hands the parsed frame straight to onEvent, query() returns JSON.parse(text) untouched, and the only nostr-tools/pure imports are finalizeEvent/getPublicKey. So the promoter gate at daemon.ts:359 compares the allowlist against a pubkey the relay supplied. (test/helpers.ts:22 gives every fixture sig: 'f'.repeat(128) and the full §6 suite drives a complete VM publish on those, so nothing can currently tell a signed event from an unsigned one.)

To be fair — and this is why it's not a blocker — this is a documented, deliberate delegation, not an oversight: docs/DESIGN.md:88-92 has an explicit ## Trust boundaries section ("Relay input is untrusted: sigs are relay-verified on write, but the daemon additionally snapshots full signed events into the KA so claims remain independently checkable"), and you audited the relay-side buzz_core::verify_event in docs/audit/buzz-audit.md:160. The shipped configs are all loopback, so there's no MITM story at the defaults. And the promotionPath claim that sources are "Schnorr-verifiable independently of the relay" holdsdeterministic.ts:96-107 writes the complete NIP-01 preimage plus id and sig, so a downstream consumer can recompute and would detect a lying relay.

The gap is that the registry entry — unlike DESIGN.md — never says so. security.notes reads as cryptographic to a committee.
→ Either add one clause to security.notes naming the relay as a trusted component, or add the ~3-line guard. I checked the fix rather than just the finding: verifyEvent on wire-shaped events returns true for a genuine event and false for a forged pubkey, the 'f'.repeat(128) sig, and tampered content. One caveat — nostr-tools memoizes on a symbol property that object spread copies, so verify the parsed wire object, not a spread copy.

11. A crash between publish and persist strands a paid on-chain publish outside all accounting.
Ordering in handleApproval: consume the approval (:409) → dkg.publish() (:417, real money) → persist published + ual (:438). If the process dies in between, the op stays state='receipted', ual=NULL, and pendingOps() selects state NOT IN ('receipted','vm_receipted','failed') (store.ts:231), so recover() never retries it — executeOp has no published branch anyway. Every restart logs recovering op while doing nothing; the UAL of a confirmed publish is lost, with no receipt in the room. Worse, countRecentPublishes counts only published/vm_receipted, so a stranded publish is invisible to the rolling-24h ceiling — real spend can exceed the declared budget by the number of such crashes.

On the adjacent double-publish worry: a second, distinct ✅ clears the dedup, the UNIQUE insert, and §6.7 (op.state !== 'receipted' || op.ual) because the local row still says receipted/NULL — it's stopped by §6.5's descriptor check instead, since a confirmed publish stamps dkg:state "published" on the node (packages/agent/src/dkg-agent-publish.ts:5847). So re-publish is guarded, but by a remote read, while the local guard is one of the four that survive mutation (#13).
→ Persist a publishing intent row before calling publish, include it in pendingOps(), resolve on recovery via the descriptor read-back already at :422-432, and count the intent toward budget so it's reserved rather than recorded after the fact.

12. BDI_MAX_PUBLISHES_PER_DAY fails open on a non-numeric value. config.ts:64 is a bare Number(...); daemon.ts:399 is recent >= max. Executed:

value parsed blocks after 999 publishes?
5 5 yes
"" 0 yes
unlimited / none NaN no

NaN makes every comparison false, silently removing the only spend ceiling — with no boot error and no log line showing the parsed value. publishMode in the same function is strictly validated and throws; apply the same treatment and log the resolved budget at startup.

13. Four of the nine advertised §6 invariants have no test. Mutating each guard to if (false) and re-running the suite, these survived with all 51 tests green: §6.3 receipt KA+digest match (:370-375) — the check security.notes names verbatim; §6.4 channel→CG remap (:363); §6.5 descriptor promoted+SWM (:378); §6.7 not-already-published (:384). The other 8 of 14 mutations were killed — promoter allowlist, single-consume, budget, graph scoping, publishMode=disabled, fail-closed-on-source-change — so the suite is genuinely load-bearing; these four are the gap, and §6.5/§6.7 are the pair guarding #11.

14. §6.5 is a comment, not an implementation. daemon.ts:376 is labelled "finalized SWM KA matches the approved digest", but the check below only asserts desc.state === 'promoted' && desc.memoryLayer === 'SWM' — nothing is compared to op.digest. Graph content is verified against the digest only at receipt time (:285-306), never re-checked at approval. The read-back helper already exists; calling it before the consume would close the window.

15. An unconfirmed publish is announced as an on-chain anchoring. classifyVmPublish (packages/cli/src/daemon/routes/knowledge-assets.ts:538) maps a tentative result to 502 explicitly to avoid a silent downgrade. But client.ts:51 throws on that, and the recovery path at :422 then reads the descriptor — which the node stamps state="published" for tentative too — so the daemon treats it as success and posts a UAL receipt into the room for a transaction that did not confirm, marking the op terminal so it never retries.

16. Every failure is silent to the room. reject() (:353-356) only records and logs — all nine §6 rejections post nothing; same for publish failed (:434) and every capture failure (:185,196,249,322-327). A promoter reacts ✅ and cannot tell "accepted" from "not authorised", "budget exhausted", or "relay blip". Worse, reject() writes a permanent rejected row for transient reasons ('receipt event no longer readable' at :369, a .catch(()=>null) descriptor read at :377), and approvalOutcome short-circuits every replay — keyed on the reaction event id, so removing and re-adding the ✅ does produce a new id and is the (undocumented) way out. Nothing tells the promoter that. The reason strings are already plain English; post them.

17. Client-controlled created_at can permanently poison the catch-up cursor. handleEvent calls advanceCursor(event.created_at) (:164) for every event — after classify() but before the if (!trigger) return — and advanceCursor takes a MAX (store.ts:131). Nostr created_at is client-set. Reproduced at the daemon level: one event dated now + 86400 pins the cursor forever, so every subsequent subscription since and every catchUp() filters in the future and matches nothing — captures and approvals stop arriving with no error and no log line. Caveat: I could not verify whether buzz-relay accepts a future created_at on write (it isn't vendored here, and NIP-01 doesn't mandate rejection though many relays clamp). Worth confirming on your side — the one-line hardening (advanceCursor(Math.min(created_at, now + 300))) is cheap either way.

18. No HTTP timeout anywhere, behind a single serial queue. grep AbortSignal|AbortController|timeout src/ returns only two setTimeout uses (the read-back backoff and WS reconnect) — no request deadline on either fetch call site. All events are serialized through one promise chain (:33,58-64), so one hung socket stalls every channel for undici's ~300 s default, and a slow ask head-of-line-blocks captures and approvals. Exposure is worst on vm/publish, which is synchronous on-chain — the node ships vm/publish-async for exactly this reason.

19. No in-process retry. handleCapture short-circuits on any existing op row regardless of state (:188-192), and recover() runs only from start()onReconnect calls catchUp(), not recover(). So a transient 503 parks a user's pin with no receipt and no channel message until an operator restarts the process. The comment at :323 ("will retry on next recovery") is technically true; the problem is that "next recovery" means "next process restart".

20. Two answering-quality issues. (a) The sufficiency gate is matched >= 2 || some(t => t.length >= 7) (grounded.ts:163-166), so one ≥7-char token anywhere returns that record verbatim as the answer — asking "did we reject X?" against "DECISION: we approved X" yields the approval text, cited. Framing output as a quotation rather than an assertion would make it honest without adding a model. (b) The refusal says "No supporting evidence was found in context graph ''", but the node returns a shape-preserving empty result when shared-memory access is unconfirmed or the CG is private — so against a private CG the bot will confidently tell the room the graph knows nothing, forever, while it's full. A trivially-true scoped ASK per bound CG at startup would catch it.

21. @dkg ask is a read oracle over the whole bound Context Graph. The retrieval pattern is ?root schema:name ?name with no type, channel, or provenance constraint (grounded.ts:80-87), and VM is queried first, so foreign VM content outranks the room's own decisions. security.notes frames scoping purely as containment ("no fallback to any other graph") and never states the converse: every member of a bound channel becomes a reader of that graph. Either constrain to room-authored clusters or say this plainly in the entry and README.

22. Promoter pubkeys are unvalidated. config.ts:40 is a bare .map(String). Buzz surfaces identities as npub1…, so the natural copy-paste is the wrong format; the daemon then starts fine, captures normally, and ignores every ✅ forever with only a logger.warn. Validate 64-char hex (or decode npub via nostr-tools, already a dependency) and fail fast.

Nits (click to expand)
  • Committed runtime state: demo-fifa/daemon.db + -shm + -wal (a live SQLite DB with WAL) and repos/.pack-cache/session-12KjbY/.heartbeat (empty). .gitignore covers data/ and demo-live/ but not demo-fifa/. Contents verified secret-free, but the -shm is per-process shared-memory state meaningless outside the running process, and anyone following run-daemon.sh inherits the author's catch-up cursor. demo-fifa/ is also referenced from no doc at all (git grep -l demo-fifa -- ':!demo-fifa' → nothing), including SPEC's repo-layout block.
  • Unbounded capture inputs. No cap in snapshotSourceSet, no limit on fetchThread, and the distiller inlines every event's full content and tags. Bounded in practice — fetchThread pulls one thread (root + direct #e replies) and the relay applies its own default limit — but a long thread can still exceed the node's staging ceiling and 400/413, which per Add DKG Contestation Protocol #19 parks the op silently. Dedup is keyed on trigger_event_id, not the digest, so N mentions of one thread create N ops and N receipts.
  • config.ts:5 required() reads process.env directly, ignoring loadConfig(env)'s injected env — proven by supplying every var via the parameter and still getting missing required env BDI_SERVICE_KEY. Latent test-integrity bug.
  • kaNameForDigest truncates to 48 bits (digest.slice(0,12)), so KA identity isn't collision-free; with no content re-check at approval ([cfi-dkgv10-r1] Add agience-flare-dkg-integration #14) nothing would catch a collision.
  • localeCompare orders the source-set digest (deterministic.ts:40) — ICU/locale-dependent for a primitive a third party may recompute. No live bug (lowercase hex), but use codepoint comparison.
  • contextGraphExists(...).catch(() => ({exists:false})) collapses any error — 503, timeout, wrong token, wrong port — into bound context graph '<id>' not present, sending operators after the wrong problem before process.exit(1).
  • A bare @dkg distill with no e tag falls back to event.id as the thread root, so it distills the command message itself and SWM-shares a KA whose name is the literal text "@dkg distill", with a normal-looking receipt. (Confirmed by writing the missing test.)
  • Citations aren't resolvable by humans[1] urn:buzz-dkg:decision:<sha256>, never the UAL or the Nostr source event ids the KA already carries via wasDerivedFrom. The evidence block lists [2]/[3] the answer text never references, and vmReceipt drops the txHash it has one line later.
  • Receipts are identifier walls — seven lines of hashes with no statement of what was captured, though the distiller already produces a name/description. A leading human line is safe (the parsers are ^…$ anchored).
  • Liveness edge: subscriptions are only sent inside the AUTH branch (relay/client.ts:192-207); the open handler just logs. Not reachable against buzz-relay, which always challenges — but it means a relay that doesn't leaves the daemon connected, logging success, and deaf.
  • No CI (.github/ absent) against a 6-month maintenance attestation — worth adding a typecheck+lint+test workflow. Relatedly test:live points at a nonexistent test/live; it appears only in package.json, not in any doc, so it's just a dead script.
  • Three npm scripts are POSIX-only. format and format:check single-quote their globs, which cmd.exe doesn't strip — on Windows format:check matches zero files and still prints "All matched files use Prettier code style!" after two errors, and README.md:81 tells contributors to run format. test:live's inline env prefix fails the same way.
  • writeAuthority over-declares POST /api/knowledge-assets — the daemon relies on wm/write auto-create; only the phase0 spike and a gate script call it. Safe direction, but a maintainer auditing against the code finds no caller.
  • devnet publishMode is undeclared in security.notes while still live in config.ts:47 and .env.example — even though DESIGN.md:76-77 and the commit subject say it was "retired". Security impact nil (pinned to evm:31337), but it's an incomplete enumeration of a security-relevant surface.
  • unquote (grounded.ts:19-21) only strips plain "…", so a typed or language-tagged literal reaches the channel as "Some Name"@en. Only matters because retrieval deliberately targets third-party domain data.
  • escapeForRegex (grounded.ts:23) is named for the wrong language and is a no-op today; the real guarantee lives in questionTerms. Worth renaming plus an assertion so widening the tokenizer later can't silently open a literal-injection hole.
  • reject() writes its approvals row before the promoter check, so any member ✅-ing any receipt inserts a row — unbounded but harmless table growth. ops/asks/approvals are never pruned.
  • Small drifts: README says "48 tests" twice (actual 51); install.oneLiner says setup is documented in docs/DESIGN.md, which has zero BDI_* content; demo points at the devnet text transcript rather than the mainnet recordings in docs/media/ (referenced as inline code paths, so GitHub renders text rather than the gifs); maintainer.contact was dropped relative to PR Add Buzz DKG Integration #20 — worth re-adding where the maintainer is the only escalation path.
  • Registry-side, not yours: CI's SWM check passed vacuously — validate.mjs:115 matches /shared-memory|\/promote/, which doesn't match swm/share. I'll raise that separately.
Verified clean — what I checked and found correct, so it isn't re-litigated (click to expand)
  • writeAuthority is complete and honest. I enumerated every /api/ call in the repo; the only state-mutating ones are exactly the five declared. No PUT/PATCH/DELETE exists in the codebase.
  • networkEgress is complete — only the Buzz relay and the DKG node. The https:// literals in the distiller are RDF namespace IRIs, never fetched. No telemetry, no phone-home. There is no LLM anywhere in src/, so no undeclared model host.
  • credentialsHandled is correctBDI_SERVICE_KEY is the only third-party secret; omitting the DKG token is exactly right per CONTRIBUTING.md:47.
  • publicInterfacesUsed: ["http-api"] holds and scope compliance is provable — imports are only node builtins, @noble/curves, nostr-tools, ws. No @origintrail-official/*, no path into the DKG tree, no node source patching, no direct store access. SPARQL only via public POST /api/query.
  • API faithfulness is excellent — all 8 routes exist on a stock 10.0.8 node; every request field is one the handler reads and every response field one it emits. Several non-obvious v10 behaviours are modelled correctly rather than guessed: wm/write auto-creates the lifecycle record; a confirmed VM publish drains the KA out of SWM (so evidence must span both views); an ASK is normalised to {bindings:[{result:'true'}]}; re-finalize of identical content returns the existing seal. Quad terms pass the node's own validators; auth header and token-file parsing match; encodeURIComponent on every KA path closes path injection.
  • Graph scoping is real and server-enforced — both read call sites pass contextGraphId + view; the node rejects a view without one. No error or empty-result path retries unscoped.
  • SPARQL injection via the question is not possiblequestionTerms reduces input to [a-z0-9-] tokens before interpolation; a crafted UNION { GRAPH <other-cg> … } payload emerged as harmless alnum tokens.
  • The approval anchor design is right. The target resolves by event id against ops.receipt_event_id, which is only ever written from the daemon's own sendMessage result — so even though the bot can be made to echo receipt-shaped text, such an event's id is never in ops. Content parsing is a secondary check, never the anchor.
  • "Consumed exactly once" is realapprovals.approval_event_id PRIMARY KEY, with the UNIQUE violation correctly mapped to false; fail-closed either way.
  • publishMode is fail-closed — defaults to disabled, throws on any unrecognised value including case and whitespace variants.
  • The 24h window arithmetic is correct — genuinely rolling, strict >, survives restart.
  • Promoters are correctly scoped per channel; self-triggering is blocked; client-supplied h tags on reactions are deliberately ignored.
  • The capture pipeline is properly resumable — each stage re-derives state, descriptor read-back detects work already done, and findExistingReceipt provably prevents duplicate SWM receipts. The finalize crash window is genuinely safe against the real node.
  • The digest is deterministic — canonical signed form, sorted, no clock or randomness.
  • The payload is genuinely single-root PROV-O, and the wasDerivedFrom chain to signed source events carries the full NIP-01 preimage plus id and sig, which is what makes the independent-verifiability claim true.
  • No in-channel spam risk — one receipt per trigger, one reply per ask, approvals consumed once.
  • Credentials are never logged or posted — all 30 logger call sites checked; error strings carry only a response-body slice.
  • Dependency posture is excellent — 12 runtime packages, all @noble/@scure/nostr-tools/ws, 0 deprecated, npm audit --omit=dev clean. Strict TypeScript (noUncheckedIndexedAccess, verbatimModuleSyntax). No god-file: 1,867 lines across 13 files, largest 499. No .skip/.only/.todo anywhere in test/.
  • install.kind: "manual" is the honest choice, not laziness — package.json is private: true with no published image or package, so service/cli would be unsatisfiable under the pinning rules.
  • .env.example is otherwise exactly in sync with config.ts — all 13 vars, no orphans, defaults match.

Nice work overall — the invariant-driven approval gate, the read-back-everywhere lifecycle, the documented trust boundaries, and the honesty of the security declarations are the strongest parts, and the gate reports are a model for how to make a submission reviewable. Items 1–5 gate merging the entry; 6 is the one real defect I'd want fixed regardless; 7–9 are what stand between an adopter and a running daemon.

Happy to re-review on a re-pin. On the kind: service question in your PR notes — I'd stay with manual for now and revisit once there's a published image; the gap here is documentation, not packaging.

Zigoljube pushed a commit to Zigoljube/buzz-dkg-integration that referenced this pull request Jul 28, 2026
…, docs

Fixes for OriginTrail/dkg-integrations#21 (Jurij89 review):
- Add Apache-2.0 LICENSE + NOTICE (blocking #1)
- Bound @dkg ask: cap question length + term set, resolve-on-error so a
  poisoned ask can't replay-brick the daemon (blocking #6)
- Reserve a 'publishing' intent before vm/publish with budget + crash
  recovery; never announce a tentative (HTTP 502) publish as confirmed (#11,#15)
- §6.5 re-reads and compares the shared source-set digest at approval (#14)
- Fail closed on a non-numeric publish budget (#12); validate promoter
  pubkeys as hex/npub (#22); clamp future created_at cursor (#17)
- Post rejection reasons back into the room (#16)
- Setup: load .env, default DKG API :9200, Node >=22.9, document mainnet
  mode + a deploy-against-an-existing-node path (#7,#8,#9)
- Nits: codepoint digest sort, bare-distill guard, untrack demo-fifa runtime
  state, quote-framed answers, test count 48->51
- 61 tests green; typecheck/lint/prettier clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…losures

Re-pin to Zigoljube/buzz-dkg-integration@3cafe11 (adds LICENSE + the code and
doc fixes from the review). Entry changes:
- requiresDkgNodeVersion >=10.0.0 -> >=10.0.8 (graph-scoped publish landed 10.0.7)
- description: drop "neuro-symbolic" for the deterministic no-model retrieval
  the code actually implements
- promotionPath: cite the fully-evidenced UAL 2201 (tx/block + gate report)
- security.notes: disclose relay-delegated event authenticity and that a bound
  channel's members are all readers of its Context Graph
- oneLiner: point setup/env at the README deploy section

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Zigoljube Zigoljube mentioned this pull request Jul 28, 2026
12 tasks
@Zigoljube

Copy link
Copy Markdown
Author

Thanks for the exceptionally thorough review — the mutation-testing and the executed measurements made every finding actionable. Re-pinned to Zigoljube/buzz-dkg-integration@3cafe11 with the fixes below; registry CI is green.

Blocking

  1. LICENSE — added Apache-2.0 LICENSE + NOTICE (copyright), plus a README license section. New commit, not a re-pin.
  2. Evidence claim — repointed promotionPath to the fully-evidenced UAL …/2201 (tx 0x6daf3e0b…, block 49145748), matching README.md / GATE_D3_REPORT.md. Dropped the unsubstantiated …/2209 reference.
  3. Version floorrequiresDkgNodeVersion>=10.0.8.
  4. "neuro-symbolic"description reworded to "deterministic, no-model retrieval scoped to that room's Context Graph," matching the code and README.
  5. PR Add Buzz DKG Integration #20 — closed, referencing this PR.

The real defect

  1. @dkg ask brick — capped the question (512 chars, dropped at classify) and the term set (MAX_TERMS=12, bounds pairs at 66); wrapped executeAsk in try/catch that resolves the ask to refused so a throw can't leave a pending row that replays on every boot; added the missing .catch() on the recovery call. New tests cover the over-length drop and the resolve-on-error path.

Setup

  1. .env loadingstart now runs node --env-file-if-exists=.env ….
  2. Deploy path — new README section "Deploy against an existing relay + node": service-key generation, getting the bot into a NIP-29 channel (+ where the service pubkey is logged), channelId source, the production 0x<Curator>/<name> CG id form, and the DKG token location.
  3. Defaults / floorBDI_DKG_API default :9420:9200; engines and README → Node ≥22.9; documented mainnet mode + the real-ETH cost warning + BDI_MAX_PUBLISHES_PER_DAY.

Should-fix

10 / 21. Relay-trust + read-oracle disclosure — added both clauses to security.notes (relay-delegated event authenticity with independently-checkable snapshots; and that every member of a bound channel is a reader of its Context Graph).
11. Publish accounting — persist a publishing intent (counted toward the 24h budget, included in pendingOps()) before the on-chain call; recovery resolves it via descriptor read-back.
12. Budget fails open — a non-numeric BDI_MAX_PUBLISHES_PER_DAY now throws at startup, same treatment as publishMode.
14. §6.5 — now re-reads the shared graph and compares its sourceSetDigest to the approved digest before consuming the approval.
15. Tentative announced as anchored — an HTTP 502 (tentative) publish is no longer read back to "published"; it fails with an honest in-room note and no UAL.
16. Silent rejections — every rejection now posts its reason to the room; transient reasons (unreadable receipt/descriptor) no longer write a permanent rejected row, so a retry works.
17. Cursor poisoningadvanceCursor clamped to now + 300s (I could not confirm buzz-relay accepts a future created_at, but the one-liner is cheap either way).
22. Promoter pubkeys — validated as 64-hex or npub1… (decoded via nostr-tools), fail-fast at startup.

Nits

Fixed: codepoint digest ordering (was localeCompare); bare @dkg distill no longer distills its own command message; answers are now quote-framed (#20a); hardened unquote (strips lang/datatype tags) and renamed/asserted the term sanitizer; untracked demo-fifa/*.db* runtime state + .gitignore; README test count 48 → 51.

Deliberately deferred (documented, non-blocking)

HTTP request timeouts (#18), in-process retry vs restart-only recovery (#19), a per-pubkey ask rate-limit (needs a schema column), and citation/receipt human-resolvability (#20b, some nits). Happy to land these too if you'd like them before merge.

Suite is 61 tests (was 51), typecheck/lint/prettier clean. Thanks again — glad to iterate on a re-review.

@Jurij89

Jurij89 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Re-review — 3cafe11

Verified against the new pin rather than the changelog: re-ran my original probes on the new code, executed the changed paths against the repo's own mocks, mutation-tested the new guards, and diffed c42efd2..3cafe11 for regressions.

Verdict: the entry is ready to merge. The publish path is not ready for publishMode=mainnet.

All five entry blockers and the blocking ask-path defect are genuinely fixed — I re-ran every measurement rather than taking the summary at face value. But the two publish-path fixes (#11 reserve-before-spend, #15 tentative honesty) interact badly: #11's new boot-recovery path re-opens the exact dishonesty #15 closes, and re-publishes without human approval. Since VM publishing is opt-in and off by default, this doesn't block listing the integration — but it should be fixed before anyone turns mainnet on.

Baseline reproduced: validator green (0 errors, 1 expected VM-scope warning); all entry fields within schema caps; typecheck + lint clean; 61 tests / 7 files. LICENSE is the genuine unmodified Apache-2.0 body with a NOTICE naming holder and year. demo-fifa/*.db* and the pack-cache heartbeat untracked. PR #20 closed.

Confirmed fixed

# How I verified
1 LICENSE Genuine 202-line Apache-2.0 + NOTICE; new commit, not just a re-pin
2 Evidence …/2201 + tx + block, all substantiated at the pin; 2209 fully retired from the entry
3 Version floor >=10.0.8, matching every gate report
4 Description "deterministic, no-model retrieval" is literally true of grounded.ts
5 Duplicate #20 closed
6a Amplification Re-ran my probe: same 9.7 KB hostile message → 1200 terms capped to 12, largest SPARQL 59 MiB → 5.6 KiB. MAX_TERMS lives inside questionTerms, the sole tokenizer, so there's no bypass; MAX_QUESTION_CHARS=512 also drops it at classify (boundary checked: 512 accepted, 513 dropped). Both caps mutation-proof
6b The brick Fixed and tested — including a real file-backed crash/restart, which now recovers and answers
7 .env Verified the flag itself: loads when present, continues when absent (container-injected env still starts), and plain --env-file would have crashed. Right flag
9 Defaults :9200; >=22.9 correctly covers --experimental-strip-types (22.6) and --env-file-if-exists (22.9)
11 Reserve-before-spend Op is durably publishing at the instant publish() is invoked; a stranded op consumes the ceiling (maxPublishesPerDay=1 → second ✅ refused, 0 publishes)
12 Budget fail-open Re-ran my probe: unlimited/none now throw at startup instead of becoming NaN
14 §6.5 Real digest re-read against the SWM view, before the consume, and fail-closed — a throwing query rejects as transient, leaving no permanent row
16 Silent rejections Posts to the room, and the transient/permanent split is coherent: transient writes no terminal row and "re-add the reaction" genuinely works
17 Cursor Math.min(created_at, now + 300)
22 Promoter format npub decode and uppercase-hex normalisation — an UPPERCASE-configured promoter's ✅ is honoured end to end, and that breaks the moment .toLowerCase() is dropped
8 Deploy docs The new section answers nearly every gap — I ran the key-gen one-liner (works), and confirmed the <DKG_HOME>/auth.token story, the 0x<Curator>/<name> form, and the npub claim against ground truth
10/21 Disclosures Both clauses accurate against the code

Also fixed unasked: loadConfig's injected env is now honoured. And the 10 new tests are real — every guard they claim to cover dies under mutation, verified with the mutation still on disk when vitest exited.

Regressions introduced by the publish-path fixes

R1 — #11's recovery path re-opens what #15 closed. completePublish's pre-check is pre.state === 'published' || 'finalized' (daemon.ts:525). But the node stamps dkg:state "published" for a tentative publish too — I read the guard myself: if (result.status === 'confirmed' || result.status === 'tentative') at packages/agent/src/dkg-agent-publish.ts:5824. So a crash during a tentative publish makes recovery announce "Published to Verifiable Memory / UAL: …" with zero publish calls — the same false anchoring claim #15 was written to prevent, arriving through a different door.

One caution on the obvious fix: gating on publishedUal does not work — it's stamped for tentative as well (assigned unconditionally as the …/t<opId> tentative form). The descriptor exposes no confirmed/tentative discriminator, so closing this properly needs an on-chain confirmation check, or persisting "we already called publish once" and refusing to announce without a fresh confirmed response.

R2 — a failed publish becomes an unattended re-publish that bypasses every §6 invariant. A non-502 failure deliberately leaves the op in publishing (:567-575); pendingOps() includes publishing; recover()executeOp:256completePublish. Executed: one human ✅ → two on-chain publish attempts (publish() calls: 2 | final state: vm_receipted), and it retries on every boot.

The serious part isn't the retry, it's the entry point: executeOp:256 calls completePublish directly, so the boot path re-publishes without re-checking the promoter allowlist, the source-set digest, the channel↔CG binding, the chain gate, or the budget. Verified adversarially — a publish succeeded on recovery after the shared digest was tampered with and the channel was re-bound to a different Context Graph. The comment at :569 ("the node dedups a re-publish") holds only for an already-confirmed KA; for a failed one it genuinely publishes again. This weakens the human-attestation gate that justifies the entry's VM scope, so it's worth fixing even though mainnet is opt-in.

R3 — the CG presence probe now fails open. .catch(() => ({ exists: false })) became .catch(err => { warn; return { exists: true } }) (:99-105). Executed: with the probe erroring, the daemon starts. A definitively-absent CG still blocks, so the check isn't gone — but any 400/500/timeout now defeats the fail-early binding check. I think this came from my nit about the misleading error message, so: the ask was to distinguish "probe errored" from "probe says absent" and report the former accurately — not to proceed on error. Worth reverting to fail-closed with a clearer message.

The #15 fix advertises a recovery that the state machine forbids

The 502 branch is right in substance — no UAL, honest note. But it posts "Re-add ✅ once the node confirms." and then refuses. I drove it end to end (temporary test file, since deleted; tracked tree verified clean):

--- after 502 ---            op.state: failed   ual: (none)
--- after re-adding the ✅ --- rejected: "Can't publish that: KA already published or in publish flow."

failed is terminal: a 9×9 transition matrix confirms the only legal edge out of failed is failed→failed. Against the real node it fails one gate earlier — the node stamped state=published for the tentative tx, so §6.5's descriptor check rejects first — but either way the instruction can't be followed, and the message the promoter sees ("already published") is false. Either drop the instruction, or add a real re-approval route.

Untested guards (worth closing given what they protect)

Mutation results against the shipped suite, each with the mutation confirmed still on disk at exit — all SURVIVED with 61/61 green:

  • the 502 branch (if (false && status === 502)) — the original defect reappears undetected. Given R1 above, this is the most valuable missing test in the PR.
  • budget-includes-publishing — the single guard keeping a stranded paid publish inside the declared ceiling.
  • §6.3 receipt KA+digest match, §6.4 channel↔CG remap, §6.7 not-already-published, and the descriptor-state half of §6.5. Only the new §6.5 content re-check gained coverage, so 3 of the 4 I flagged last round are unchanged. §6.7 is the guard involved in the dead-end above.

Smaller items

  • A wrong BDI_DKG_API port doesn't produce the promised diagnostic. The README now says the symptom is ECONNREFUSED; the operator actually gets {"message":"daemon failed to start","err":"TypeError: fetch failed"}. err.cause.code is ECONNREFUSED — logging err.cause alongside err makes the troubleshooting note true.
  • A 502 releases the budget reservation. failed isn't counted, so N tentative publishes cost N real gas attempts while the ceiling reads 0 — the one path where a tx may already have cost money.
  • published → vm_receipted is never resumed. If the receipt send fails, the op stays published; recover() re-lists it every boot but executeOp has no branch, so the room never learns about a publish it paid for. Pre-existing, not from this round.
  • Silent drops. An over-length ask, a bare @dkg distill, and an internally-failing ask all reply with nothing. The over-length refusal is the right safety choice (it refuses rather than silently answering a different question) — it just isn't announced, which cuts against this round's own "post the reason to the room" principle.
  • Approver attribution blanks to approved-by: only on the crash-recovery path when the approval event is unreadable; the normal path passes the pubkey explicitly and is fine.
  • package-lock.json still records "node": ">=22.5" while package.json says >=22.9.
  • The Node floor is still one notch too low. >=22.9 is right for the two flags you cite, but src/registry/store.ts:8 requires node:sqlite at module eval, and that was flag-gated until v22.13.0 (release notes: "unflag sqlite module", #55890). So 22.9–22.12 is still a band where the daemon cannot boot at all — reproduced as ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite. Same class as the original >=22.5; >=22.13 closes it, and README.md:62 / :117 need the bump too.
  • Stragglers: deploy/docker-compose.integration.yml:29 still defaults to :9420; README still says "51 tests" in two places (actual 61); the mainnet section still omits the prerequisites (publisher identity, funded wallet, on-chain-registered CG) and a dry-run path; phase0/README.md's .env.spike generator is still the no-op heredoc; three .env.example vars have no inline comment despite "every var is documented inline".
  • The PR description was never updated — it still pins c42efd2, still says "neuro-symbolic retrieval", and still cites …/2209. The JSON is what merges, but the body is the submission record.
  • Not a defect: npm run format:check goes red on a Windows clone with core.autocrlf=true, but passes with --end-of-line auto — the code is formatted and the script quoting was correctly fixed. A .gitattributes or endOfLine: "auto" would spare the next Windows contributor.

On the deferred items

Agreed on priorities, with one update: the approval path now makes 6 unbounded, untimeouted round trips, up from 4 (the #11 pre-check and the #14 digest re-read are both new), and the new digest re-read is the same SPARQL shape that already needed a bounded retry elsewhere. That makes #18 more load-bearing than when I first raised it.


Strong turnaround — the ask-path fix is complete and well-tested, completePublish is a better design than what I suggested, and the new tests target real failure modes rather than coverage. R1 and R2 are the ones I'd want resolved before mainnet is switched on anywhere; from a registry standpoint I'm happy for this to merge once you've decided how to handle them.

Zigoljube pushed a commit to Zigoljube/buzz-dkg-integration that referenced this pull request Jul 28, 2026
Re-review of 3cafe11 (OriginTrail/dkg-integrations#21) found the #11/#15
publish fixes interacted badly. Redesign around a terminal
'publish_unconfirmed' state:
- R1: never decide publish success from the descriptor — the node stamps
  state='published' for a TENTATIVE tx too. A UAL is announced ONLY on a
  confirmed publish() response (one carrying a txHash).
- R2: boot recovery of a 'publishing' op no longer re-publishes (that bypassed
  every §6 human-attestation gate) — it records the op unconfirmed instead.
- 502/error/lost-response are all terminal 'publish_unconfirmed': honest note,
  counted toward the 24h budget (gas may have been spent), never auto-retried.
- Dead-end fixed: the note no longer says "re-add ✅" (a route the state
  machine forbids); it says to re-capture the thread.
- R3: CG presence probe fails closed again, with a message distinguishing
  "probe errored" from "CG absent".

Also:
- Resume a stranded published->vm_receipted op (re-post the receipt, read-back
  guards a double)
- Log err.cause so a wrong BDI_DKG_API port shows ECONNREFUSED, per the docs
- Node floor -> >=22.13 (node:sqlite unflagged there); README + package-lock
- deploy compose default :9420 -> :9200; README test count -> 61;
  document the remaining .env.example vars; add .gitattributes (eol=lf)
- 8 new/updated tests: 502 branch, budget counting, R1/R2/R3, §6.3/§6.4/§6.5
  state, published->vm_receipted resume. 69 tests green; lint/prettier clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-pin to Zigoljube/buzz-dkg-integration@9195710, which fixes the three
publish-path regressions from the re-review (R1 tentative-vs-confirmed, R2
unattended re-publish on boot, R3 fail-open CG probe) plus the Node floor
(>=22.13). Entry text: bump the oneLiner's Node prereq to >=22.13.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Zigoljube

Copy link
Copy Markdown
Author

Thanks for catching these — you were right that #11 and #15 fought each other. Re-pinned to 9195710 (registry CI green). The root cause was treating the descriptor as a confirmed/tentative discriminator, which it isn't; the redesign removes that assumption entirely.

Regressions

R1 — recovery re-opened the tentative dishonesty. completePublish is gone. The new performPublish announces a UAL only on a confirmed publish() response (one carrying a txHash) — it never reads the descriptor back to decide success, so a tentative tx (which the node also stamps state="published" / …/t<opId>) can no longer arrive as an anchoring claim through the recovery door.

R2 — boot re-publish bypassed §6. The recovery path no longer calls the publish routine at all. executeOp for a publishing op now records it terminally (see below) instead of re-invoking publish(), so there is no code path that publishes without a fresh human ✅. Verified: recovery of a publishing op with the node stamped published0 publish calls, no UAL.

Both are folded into one honest terminal state, publish_unconfirmed: reached by a 502 tentative, any publish error, a txHash-less response, or a crash that leaves an op publishing on boot. It posts an honest note, is counted toward the 24h budget (a tx may already have cost gas — this also fixes your 502-budget-release follow-up), and is never auto-re-published.

R3 — CG probe fail-open. Reverted to fail-closed, with the message split you asked for: a probe error now throws could not verify bound context graph '…' (presence probe errored: …) — refusing to start, distinct from the definitive not present message.

The dead-end message

Fixed. The unconfirmed note no longer says "re-add ✅" (which failed/§6.7 forbid); it says to re-capture the thread (a new pin or @dkg distill), which is a route that actually works.

New tests (69 total, up from 61)

Closing the guards you mutation-flagged:

  • 502 branch — asserts no UAL, no dead-end instruction, publish_unconfirmed, budget += 1
  • budget counts publishing + publish_unconfirmed — the stranded-spend guard
  • R1/R2 — recovery neither re-publishes nor announces
  • §6.3 receipt content mismatch, §6.4 channel↔CG remap, §6.5 descriptor-state, §6.7 already-past-receipted (the double-publish gate) — each rejects with 0 publishes
  • published → vm_receipted resume — a confirmed publish whose receipt send crashed is re-posted (read-back guards a double)
  • R3 — probe error refuses start

Smaller items

  • ECONNREFUSED diagnostic — startup now logs err.cause.code, so a wrong BDI_DKG_API port prints ECONNREFUSED as the docs promise.
  • published → vm_receipted never resumed — now has a recovery branch (above).
  • Node floor — you're right, node:sqlite was flag-gated until 22.13.0; bumped engines, package-lock.json, and both README prereqs to >=22.13.
  • Stragglersdeploy/docker-compose.integration.yml default :9420:9200; README "51 tests" → 61; the three bare .env.example vars now documented; .gitattributes (eol=lf) added so format:check is green on a Windows clone.
  • PR description — updated: re-pinned SHA, "deterministic no-model retrieval", UAL …/2201 with tx/block.

Still deferred (acknowledged)

#18 (HTTP timeouts) I've left for a follow-up, but noted your point that it's now more load-bearing at 6 round trips — the new §6.5 digest re-read reuses the bounded-retry SPARQL shape, so the worst offender is contained, but a real per-request deadline is the right fix and I'll prioritise it. Also still deferred: #19 (in-process retry), a per-pubkey ask rate-limit, phase0's no-op .env.spike heredoc, and citation human-resolvability.

Approver attribution can still blank only on the crash-recovery receipt when the approval event is unreadable — best-effort by design; the normal path passes the pubkey explicitly.

Thanks again for the depth here — the tentative/confirmed distinction in particular was exactly the kind of thing the descriptor made easy to get wrong.

/rate-limit/#20b)

Re-pin to Zigoljube/buzz-dkg-integration@0a5de8b: HTTP request timeouts (OriginTrail#18),
in-process capture retry on reconnect (OriginTrail#19), a per-pubkey @dkg ask rate limit,
and human-resolvable receipts + citations (#20b). No entry-field changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Zigoljube

Copy link
Copy Markdown
Author

Landed the deferred items too — re-pinned to 0a5de8b (registry CI green, 74 tests).

#18 — HTTP timeouts. Every DKG and relay fetch now carries a deadline via AbortSignal.timeout: DKG requests 30s, vm/publish 180s (it's synchronous on-chain), relay 15s. A TimeoutError is re-thrown as a legible … timed out after Nms. This bounds the head-of-line blocking you flagged — with everything serialized through one queue, a hung socket can now stall the queue for at most the deadline, not undici's ~300s.

#19 — in-process retry on reconnect. onReconnect now runs resync(): drain the queue, retry any parked capture op (distilled/wm_written/finalized/shared) in place, then catch up. So a transient relay 503 that parks a pin's receipt clears on the next reconnect instead of waiting for a restart. It deliberately skips publish-stage ops — no unattended on-chain retry on the concurrent path (that stays with boot recovery or a fresh ✅), which keeps R2 closed.

Per-pubkey ask rate-limit. Added asks.author_pubkey and a 12/min per-pubkey ceiling; over-limit asks get an honest in-room refusal rather than fanning out more scoped SPARQL.

#20b — resolvable citations + receipts. New ops.title and ops.tx_hash columns (additive SQLite migrations for existing DBs):

  • Receipts now open with a human line — Captured "…" / Published "…" — before the identifier block (still ^…$-anchored, so the approval parsers are untouched).
  • The VM receipt now carries tx: <hash>.
  • Answers cite only [1] (the record actually quoted); any other matches are listed under "Also matched" without a bracket number the answer never uses. Each citation line carries the source-set digest + view (SWM/VM), which cross-references the same source-digest in the in-channel receipt — so a citation resolves to a real, checkable record.

New tests (74 total): the reconnect capture retry, a check that resync never touches a publish-stage op, an ask-flood rate-limit, and the receipt/citation formatting.

Still open, by choice: full UAL/Nostr-source-id resolution inside @dkg ask (the digest+view cross-reference is the interim), #19's periodic (non-reconnect) retry, and the phase0 .env.spike heredoc. And the approver-attribution blank remains only on the crash-recovery receipt when the approval event is unreadable — best-effort by design.

That covers everything from both reviews I'm aware of. Thanks again for the exceptionally careful passes — happy to do another round or answer anything before merge.

@Jurij89

Jurij89 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Final review — 9195710

Verified by execution against the new pin: re-ran my probes, drove the changed paths against the repo's own mocks and against a faithfully state-gated mock, mutation-tested the new guards with null and positive controls, and diffed 3cafe11..9195710.

Verdict: the entry is accurate and I'd merge it. R1, R2 and R3 are genuinely fixed, and the redesign is the right one — performPublish announces only on a confirmed txHash-bearing response and never consults the descriptor to decide success, which removes the assumption that caused all three regressions. Nothing below is a safety defect: the system now fails safe in every case I could construct. What remains is one recurring-class UX defect and a set of doc/coverage items I'd fold into whatever commit you make next.

Baseline reproduced: validator green (0 errors, 1 expected VM-scope warning); typecheck, lint clean; 69 tests / 7 files; all entry fields within schema caps; PR description updated (correct SHA, no "neuro-symbolic", UAL …/2201 with tx + block).

Verified fixed

Item Evidence
R1 performPublish announces only on a txHash-bearing response; no descriptor read-back anywhere in the decision. Soundness checked from the other side too: the node emits status:'confirmed' only alongside onChainResult.txHash, so the new check can't false-negative a real confirmation
R2 No path publishes without a fresh ✅ — performPublish now has exactly one call site (inside handleApproval). A publishing op on boot goes terminal instead of re-publishing; driven over a real process restart on on-disk SQLite: 0 publish calls
R3 Fail-closed again, with the message split I asked for — a probe error now refuses start, distinct from the definitive "not present"
publish_unconfirmed Terminal in the transition matrix, counted by countRecentPublishes, excluded from pendingOps. The budget is a genuine self-healing 24h rolling window, not a permanent burn
published → vm_receipted New resume branch; the read-back genuinely prevents a duplicate receipt
ECONNREFUSED Executed against a dead port: {"message":"daemon failed to start","err":"TypeError: fetch failed","cause":"ECONNREFUSED"} — the README's promise is now true
Node floor >=22.13 in engines, package-lock.json, and both README prereqs, with the right rationale (node:sqlite unflagged at 22.13)
.gitattributes Actually works — I cloned fresh with core.autocrlf=true, got LF files, and format:check exits 0
Budget atomicity Check-then-act is safe: events are serialised on one promise chain and recover() completes before subscribe()/connect(), so concurrent approvals can't both pass the ceiling
Stragglers compose port → :9200; the three bare .env.example vars documented; entry re-pinned; oneLiner updated

The new tests are good work — the four §6 tests and the R1/R2 tests assert observable outcomes (publishes === 0, not.toContain('UAL:')) rather than internal state, which is the right style. 9 of 13 publish-path mutants die against your own suite.

The one thing I'd still fix: "re-capture the thread" is a silent dead end

Last round's re-add ✅ pointed at a route the state machine forbade. The replacement is safer — it no longer misstates publication — but for the literal instruction it still doesn't work, and now it fails silently.

kaName is buzz-dkg-<sourceSetDigest[0:12]>. A bare re-pin doesn't change the source set (kind 40004 isn't in fetchThread's [9,40002,40003] filter, and the bot's own note is filtered by servicePubkey), so the new op targets the same kaName — which on the node is already promoted/SWM or published/VM. The first node call then hard-fails:

POST /api/knowledge-assets/buzz-dkg-…/wm/write
→ Assertion "…" is not an active Working Memory draft (KA_WM_LIFECYCLE_REQUIRED)

That's the node's own gate (assertWorkingMemoryLifecycleMutable requires state==='created' && layer===WM), and it applies whether or not the tentative tx landed. executeOp's catch is log-only, so nothing is posted to the room, and the doomed op stays in pendingOps and re-fails on every boot.

The route works only if the thread gained a non-service message tagging the root in between — which changes the digest and the KA name. That's a real escape hatch, but it's conditional and invisible to the operator, and it isn't what the message says.

Worth noting why the suite didn't catch this: MockDkg.write/finalize/share don't consult ka.state at all, so on your mock the re-capture appears to work end to end. And the one new assertion touching this message is not.toMatch(/re-add/i) — which passes for any replacement text, including one pointing at a route that does nothing.

Cheapest correct fix is any of: say the truth ("I can't retry this automatically — verify the tx on-chain, then post a message in the thread before re-capturing; a bare re-pin won't work, the content is unchanged"); or detect the collision up front — registry.opByKaName already exists and is currently unused — and explain it in-room; or, most generally, make executeOp report capture failures to the room instead of dropping them, which also closes the wider silent-failure gap.

Coverage gaps worth closing

  • hardening.test.ts > … > §6.5 rejects when the descriptor is not in finalized+shared SWM state does not test that guard. Deleting if (desc.state !== 'promoted' || desc.memoryLayer !== 'SWM') leaves the suite at 69/69 green. I verified this in a private copy with the mutation confirmed on disk at exit, a null control (comment-only → 69/69, as expected) and a positive control (disabling the promoter allowlist → rejects unauthorized reactors fails, 68/69). The §6.5 content re-check is genuinely covered; it's the descriptor-state half that isn't, despite the test name.
  • R1's txHash-less-200 branch and the pendingOps exclusion also survive mutation — both behaviourally correct, just unasserted (MockDkg.publish always returns a txHash).
  • The §6 tests assert publishes === 0 and outcome === 'rejected' but never the reason, so rejection reasons are interchangeable without any test noticing.

Smaller items

  • docs/DESIGN.md — which the entry links as designBrief — still documents the deleted anti-pattern. Lines 49-50: "publish ambiguity: never blind-retry — descriptor read-back; published + reservedUal ⇒ success recovered." That is verbatim R1, and daemon.ts:521-525 now says the opposite in a comment. The state diagram also omits publish_unconfirmed. docs/ wasn't touched this round. A committee member reading the linked brief would reasonably conclude R1 wasn't fixed — worth ~3 lines.
  • A pre-chain 4xx burns a budget slot and overstates the risk. markUnconfirmed is used for any publish error, so a 409 VM_PUBLISH_PRECONDITION — which definitively never reached the chain — consumes a mainnet budget slot and tells the room "a transaction may already have been submitted". Worth distinguishing definitively-pre-chain statuses from genuinely-ambiguous ones.
  • HTTP 207 is still consumed as full success. res.ok is true for 207, so contextGraphError is discarded and the room gets a clean "Published to Verifiable Memory … context-graph: …" receipt. I checked reachability rather than just the handling: on v10.0.8 contextGraphError is only ever read, never assigned, so a stock node doesn't currently emit 207 — this is latent, not live. But the node's own first-party adapter states the contract explicitly ("must NOT be reported as full success"), so it's worth the ~5 lines before it becomes reachable.
  • README drift, third round running: still says "61 tests" in two places (actual 69), and deploy/docker-compose.integration.yml:3 still says "Node ≥22.5" against the new >=22.13 floor. The README's approval section also still describes a binary success/rejection model and never mentions publish_unconfirmed, what an operator should do about it, or that it consumes budget.
  • Minor robustness: a confirmed response lacking ual would post a literal UAL: null (the guard checks txHash, the receipt prints ual); and findExistingReceipt matches any service-authored thread message containing the substring trigger: <id>, which an @dkg ask answer could in principle contain.

What I tried and couldn't break

For the record, since this is the last pass: the op-state graph has no reachable state without an exit; the mainnet budget check-then-act is atomic; the SQLite schema upgrades cleanly from the very first pin (c42efd2) to this one; the capture pipeline and full WM→SWM→receipt→✅→VM lifecycle still complete with exactly one of each external call; the §6 gates all still fire; SPARQL scoping and injection-resistance are intact; and the new in-room rejection messages leak nothing — I made the descriptor throw an error string containing a token and a path, and the room received only the fixed reason text.


That's a genuinely solid landing. The publish path is in much better shape than when I started — the txHash-only rule is the kind of invariant that stays correct under future edits, which the descriptor read-back never would have. From my side: merge whenever you're ready; I'd just fold the re-capture message, the DESIGN.md lines and the §6.5 test into your next commit rather than block on them.

@Jurij89

Jurij89 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Addendum — one correction, plus the new pin

Correction: I overstated the re-capture finding

My previous comment said the "re-capture the thread" instruction doesn't work for the case it names. That's too strong, and the distinction matters: @dkg distill — named in the same sentence — works every time. Only the bare re-pin half silently fails.

The reason is the source set. fetchThread pulls kinds [9, 40002, 40003] tagged to the root, and snapshotSourceSet keeps everything not authored by the service. An @dkg distill reply is a kind-9 human message carrying an e tag to the root, so it joins its own source set and changes the digest. A pin is kind 40004 and isn't in that filter, so it changes nothing. Executed on 9195710:

original pin   kaName: buzz-dkg-347f0a373a57
bare RE-PIN    kaName: buzz-dkg-347f0a373a57   same as original? true    → node rejects wm/write
@dkg distill   kaName: buzz-dkg-86deccea3150   same as original? false   → clean path, works

So the message gives the operator a working route; it just also offers one that silently no-ops. That's a much smaller thing than I made it, and it isn't merge-relevant. Worth a word-level fix ("re-capture with @dkg distill — a bare re-pin won't work, the content is unchanged"), and the underlying wart is still that executeOp drops capture failures without telling the room.

The PR moved after I reviewed

My review was against 9195710; the head is now 80c1a8bb, pinning 0a5de8b, which lands the four deferred items (#18 HTTP timeouts, #19 reconnect retry, per-pubkey ask rate limit, #20b resolvable citations) — 277 insertions including 107 lines of new tests. I hadn't looked at that when I wrote "merge whenever you're ready", so treat this as the verification of what's actually pinned now.

Checked on 0a5de8b: typecheck, lint and prettier --check clean; 74 tests / 7 files green. Spot-checks:

  • Add integration: memorygraph-dkg (verifiable persistent memory for AI agents) #18AbortSignal.timeout(...) on both the DKG client and the relay HTTP path, with an explicit timed out after Nms error rather than a bare fetch failed. This was the deferral I said had become more load-bearing at 6 round trips, so good to see it closed.
  • Add DKG Contestation Protocol #19 — reconnect now drives recovery, and the comment is explicit that it deliberately does not touch publish-stage ops. That's the right call: re-entering the publish path on reconnect is exactly what R2 was about.
  • Ask rate limitASK_RATE_LIMIT_PER_MIN = 12 with a per-pubkey windowed count in the store.
  • #20b — receipt/citation changes present.

I have not put this pin through the full adversarial pass the previous three got, so treat the above as a smoke test rather than a clearance — the publish-path invariants are untouched by this diff, which is the part I'd have worried about.

Still open on 0a5de8b

  • The §6.5 descriptor-state test is still a false cover. Re-ran on the new pin: deleting if (desc.state !== 'promoted' || desc.memoryLayer !== 'SWM') leaves the suite at 74/74 green, mutation confirmed on disk at exit. (Harness validated earlier with a null control and a positive control.) The §6.5 content digest re-check is genuinely covered — it's the descriptor-state half that isn't, despite the test name.
  • The re-capture message is unchanged at daemon.ts:600.
  • docs/DESIGN.md:49-50 still documents the deleted descriptor-read-back design, and the README still says "61 tests" (now 74).

None of that changes my recommendation — merge when you're ready. Nice work landing the deferred items rather than leaving them as debt.

@Jurij89
Jurij89 merged commit 1c8b4b7 into OriginTrail:main Jul 28, 2026
1 check passed
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