mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-11-09 22:13:39 +09:00
e036e35dd74c19e61c240c75b80bbdfdee0d781e
7481
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e036e35dd7 |
dream(memory): #3231 wire embedding near-dup detection into MemoryConsolidator.dedup() (evaluated, ACCEPT) (#3232)
* dream(memory): wire embedding near-duplicate detection into MemoryConsolidator.dedup()
MemoryConsolidator.dedup() only ever deduplicated entries via byte-exact
SHA-256 content hashing, even though every entry already carries a
computed .embedding and the adapter's HNSWIndex (already held in scope
for removal bookkeeping) is incrementally kept in sync. Paraphrases and
reformattings of the same memory were never caught.
Adds a second pass: for hash-pass survivors with an embedding, query the
already-populated HNSWIndex for cosine-similarity neighbors above a
configurable similarityThreshold (default 0.95, matching the unwired
domain-layer consolidator's own constant), and merge using the same
keeper-selection strategies (now factored into shared selectKeeper/
mergeGroup helpers). Guarded to cosine-metric indexes only; disabled via
similarityThreshold >= 1.
Evaluation: 471/472 passing in @claude-flow/memory (1 pre-existing,
unrelated, environmental failure); baseline (git-stash-isolated source)
fails exactly the 2 new discriminating tests and passes the rest,
confirming the fix is additive and non-regressive. tsc --noEmit clean.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): fix-forward — converge near-dup pass to a fixed point in one dedup() call
Independent adversarial critique (STEP 10) reproduced a real, bounded gap
in the prior commit: a near-duplicate cluster larger than
NEAR_DUP_SEARCH_K (8) split into multiple leftover sub-group survivors
within a single dedup() call, because each round's `consumed` bookkeeping
permanently excluded a group's keeper from further matching even though
it was still fully present in the index. 15 pairwise-identical-embedding
entries collapsed to 2 survivors (merged: 13) instead of 1 (merged: 14).
It self-healed across repeated runAll() calls (background timer /
nightlyLearner), so this was never a permanent-data-loss bug, but a
single manual dedup() call under-converged relative to its documented
"collapse duplicates" contract.
Fix: loop pass 2 to a fixed point (re-scan until a full round produces
zero merges) instead of a single scan. Always terminates — entries
strictly decrease each round that merges anything. `groups` now counts
merge operations across all rounds, which can exceed the number of
underlying duplicate clusters when one needed more than one round;
documented in the method's doc comment.
Added a discriminating regression test reproducing the critic's exact
15-entry scenario; confirmed it fails against the pre-fix single-round
code (merged: 13) and passes against this fix (merged: 14, single
survivor). Full package suite: 472/473 passing (1 pre-existing,
unrelated, environmental failure, unchanged from before this commit).
tsc --noEmit clean.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): 2026-09-08 research gist — embedding near-dup consolidation
Full SOTA report: 5-role parallel research fan-out, ledger check with
GitHub-verified fates for the last 14 rows (3 merged since 09-03, one PR
now crossing the 14-day stale threshold for the first time this cycle),
competitor comparison (CrewAI/Mem0/Zep/LangMem/Qdrant/SemDedup), plugins
and automation scan findings, adversarial critique with a real bug found
and fixed, and witness stamp.
No gist-creation MCP tool available in this session (GitHub MCP tools
cover issues/PRs/repos, not gists) — committing to docs/dream-cycle/
instead, matching every dream-cycle night since 2026-08-14.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): #3231 update LEDGER.md for 2026-09-08
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): #3231 empirical receipts for dedup() near-dup pass (response to review)
Response to PR #3232's INCONCLUSIVE review, which asked for a threshold
contract rather than a smoke fixture: a contamination-checked embedding
corpus with pinned model, precision/recall including hard negatives and
cross-namespace cases, baseline vs. candidate latency/memory at realistic
cardinality, and deterministic replay + unchanged exact-dedup behavior.
Real pinned-model corpus: attempted directly against both ONNX backends
this monorepo ships. Both fail for reasons unrelated to this candidate,
reproduced on an unmodified checkout before writing anything:
- @huggingface/transformers@4.2.0: transformers.node.mjs does
`import { Tensor } from "onnxruntime-common"` against a CJS module — a
real ESM/CJS interop break under this sandbox's Node version.
- @xenova/transformers@2.17.0: pulls in sharp@0.32.6, whose native
binding (sharp-linux-x64.node) isn't present for this platform — the
same failure independently reproduced moments later by this package's
own nightlyLearner test, which falls back to mock embeddings.
Neither is fixable within this PR's scope without touching unrelated,
pre-existing native/module-resolution infrastructure. Documented in
detail at the top of the new test file rather than silently worked
around or omitted.
What's delivered instead, in
v3/@claude-flow/memory/src/consolidator-embedding-benchmark.test.ts:
- A deterministic synthetic corpus where pairwise cosine similarity is
constructed EXACTLY via vectorAtSimilarity() (Gram-Schmidt against a
random orthogonal vector), not measured after the fact — a rigorous
instrument for threshold mechanics specifically, disclosed as not
validating real-world semantic accuracy.
- Precision/recall/false-merge-rate across a 5-point threshold sweep,
every point at a deliberate margin from every corpus similarity value
after an exact-boundary collision (sweep value == corpus value) proved
float32-jitter-sensitive while writing this — documented as a finding,
not silently patched around.
- Namespace isolation: proved cross-namespace near-dup merging matches
cross-namespace hash-exact merging exactly (both pre-existing,
unscoped-by-namespace behavior — this candidate doesn't change it).
- Determinism: 3 independent runs, compared by deterministic entry KEY
(not raw id, which is randomly generated per store() and was a second
bug this file's own first draft had to fix).
- Latency/memory at N=5000 (matching this repo's own HNSW-benchmark
convention): initially measured 9.8s for the candidate pass, which
traced to an unset `ef` search parameter defaulting to efConstruction
(200) — every dedup() search traversed a 200-candidate list for a
duplicate-detection task that only needs very-close neighbors. Fixed
by passing an explicit NEAR_DUP_SEARCH_EF=32 in consolidator.ts,
re-measured at 3.7s (~2.7x), precision/recall unchanged (still exact).
Real numbers logged in the test output either way, not asserted away.
Full package suite: 478/479 passing (472 pre-existing + 7 new; same 1
pre-existing unrelated environmental failure as every prior commit on
this branch). tsc --noEmit clean.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): #3231 add new benchmark file to CI test-ratchet baseline
CI on a2e99cf failed with "1 unexpected failing file(s): +
v3/@claude-flow/memory/src/consolidator-embedding-benchmark.test.ts".
Root-caused via the vitest JSON report artifact (test-results-ubuntu-latest,
downloaded and parsed directly, not guessed): a suite-level collection
failure, "Cannot find package '@claude-flow/security' imported from
'.../v3/@claude-flow/memory/src/agentdb-retrieval-guard.ts'" — the same
unbuilt-sibling-package gap already accepted in scripts/ci-test-baseline.txt
for 9 other @claude-flow/memory test files, including consolidator.test.ts
itself (line 95). Every file in this package fails identically in this CI
checkout state; my new file just wasn't in the baseline yet because it's
new. Not a regression this PR introduced — confirmed by the failure being
a pre-import-time module-resolution error, not a test assertion in the
new file's own logic (which passes locally in a checkout with @claude-flow/
memory's dependencies actually built: see the 6/6 local run in the prior
commit's message).
Fix: add the new file to the baseline, alongside its 9 memory-package
siblings already there, matching this repo's own established convention
for this exact gap rather than working around it some other way.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01H4kfasS4XkYaSGYXFLSSEG
* dream(memory): #3231 baseline 2 more federation/gateway plugin test files
CI on
|
||
|
|
755b6720c5 |
dream(intelligence): #3159 LearningBridge.consolidate() reward-blind fix (evaluated, ACCEPT) (#3160)
LearningBridge.consolidate() reported every completed trajectory's reward as a hardcoded 1.0, regardless of the underlying insight's actual quality. Now resolves the real confidence via backend.getByKey(namespace, key) — the entryId flowing through this bridge is the caller's key, not the backend's internal id, a distinction the round-1 fix got wrong and an adversarial critic caught before this PR. Baseline 56/56 -> candidate 62/62 tests; full package 464/465 (1 pre-existing environmental failure, unrelated). Also recovers 9 nights (2026-08-24..2026-09-01) of missing ledger rows — pipeline ran to completion each night but the ledger-append step silently failed, same pattern as the 08-14..08-19 gap recovered on 08-19. Claude-Session: https://claude.ai/code/session_014pTYiv93Ft6xAN8FAaWH5s Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
82595ec557 |
dream(security): #3151 sliding-window reset for maxToolCallsPerTurn (evaluated, ACCEPT) (#3152)
* dream(security): #3151 sliding-window reset for maxToolCallsPerTurn (evaluated, ACCEPT) Follow-up to PR #3139 (2026-08-31, unmerged): the opt-in MCP policy enforcer's maxToolCallsPerTurn was a permanent session-lifetime cumulative counter that never reset, explicitly disclosed as unsafe to enable by default. Tonight's research (5 parallel roles) converged on a wall-clock sliding window as the correct fix, corroborated by the MCP spec's own silence on rate-limit mechanism, SEP-2567's removal of the session concept, Cloudflare's production sliding-window-counter data, and how FastMCP/PolicyLayer solve the same problem in the MCP ecosystem. - checkAndRecordCall/evaluateToolCall now take an injectable `now` (defaults to Date.now(), zero production behavior change at the one real call site in mcp-server.ts). - New McpPolicy.turnWindowMs field (default 60000ms); shipped in .harness/mcp-policy.json alongside the existing maxToolCallsPerTurn. - 30/30 tests passing (24 pre-existing unmodified + 6 new). Broader MCP-suite sweep: 52/56 (4 failures are pre-existing unbuilt-sibling- package environmental gaps, unrelated to this diff). - Independent adversarial critique: CONFIRMED-WITH-CAVEATS. Two minor findings (untested exact window boundary; audit-log timestamp cosmetic desync from the injected clock) fixed before this commit. Full report: docs/dream-cycle/dream-gist-2026-09-01.md * dream(security): #3151 update LEDGER.md for 2026-09-01 Ledger row for tonight's dream-cycle: sliding-window reset for maxToolCallsPerTurn (PR #3152, stacked on #3139). * dream(security): #3151 address ruvnet's PR #3152 review (round 1) Retargeted PR #3152's base to main: dream/2026-08-31-security is not main/develop, so every required CI workflow (ci.yml, integration-tests.yml, cve-audit.yml, v3-ci.yml, verification-pipeline.yml) is configured to trigger only on PRs targeting main/develop and structurally never ran on either #3139 or this stacked PR's head, regardless of what was pushed. This is a repo-wide trigger-configuration fact, not fixable by a commit. Also addresses the review's other concrete asks: - Built the two sibling packages disclosed as unbuilt (@claude-flow/cli-core, @claude-flow/neural) via tsc. Broader MCP-suite sweep improves from 52/56 to 201/204 -- remaining 3 failures confirmed (independently, by testing a raw ::1 bind outside any test) to be EAFNOSUPPORT: IPv6 loopback binding unsupported at the container/OS level in this sandbox, unrelated to this diff and not fixable by any code change here. - Added an integration test exercising the real stdio dispatcher (MCPServerManager.handleMCPMessage) through a full deny -> expiry -> allow cycle with fake timers, proving the fix holds without bypassing audit logging or the opt-in flag -- the review's specific ask, previously only covered at the direct evaluateToolCall() call level. - Strengthened documentation (McpPolicy.maxToolCallsPerTurn JSDoc and .harness/mcp-policy.json's own field comment): explicit that despite the field name this is wall-clock rate limiting, not a literal per-conversational-turn counter -- MCP has no protocol-level turn concept. 31/31 policy-enforcer tests passing (was 30/30; +1 dispatcher-level test). --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
e7fb8edc19 |
dream(security): #3138 opt-in MCP governance policy enforcement (evaluated, ACCEPT) (#3139)
* dream(security): #3138 opt-in MCP governance policy enforcement (evaluated, ACCEPT) .harness/mcp-policy.json declared auditLog/maxToolCallsPerTurn/dangerousPatterns but was only ever read by the offline metaharness mcp-scan linter -- the actual running claude-flow MCP server (mcp-server.ts) never consulted it, so any connected client could call every tool with no audit trail and no call budget. Adds an opt-in PolicyEnforcer (RUFLO_MCP_ENFORCE_POLICY=1, default off) wiring auditLog + maxToolCallsPerTurn into the real tools/call dispatch path. Default behavior is unconditionally unchanged. 16/16 new tests, 39/43 broader MCP regression sweep (4 failures traced to pre-existing unbuilt sibling packages, unrelated to this change). Independent adversarial critique found one real, disclosed limitation (maxToolCallsPerTurn is session-lifetime cumulative, not true per-turn) -- documented in the module, gist, and issue; not a blocker for landing opt-in/default-off. Full research, hypothesis, evaluation receipt, and witness: issue #3138 and docs/dream-cycle/dream-gist-2026-08-31.md. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01QqhXjtDofNNJPX3CCVQyrk * dream(security): fail-closed on policy load/audit-log failure (review round 1) Addresses ruvnet's PR review on #3139: an enforcement feature that silently falls back to unrestricted execution when its own config is missing/malformed, or when a mandatory audit-log write fails, isn't real enforcement. - loadMcpPolicy() returning null (missing/malformed .harness/mcp-policy.json) now denies the call instead of falling through unconstrained. - A failed appendAuditLog() write, when policy.auditLog is true, now denies the call instead of silently proceeding unaudited. - New evaluateToolCall() is the single enforcement entry point combining fail-closed policy loading + budget + fail-closed audit logging; mcp-server.ts's tools/call wiring simplified to call it directly. - 8 new tests: fail-closed on null policy, fail-closed on malformed JSON on disk (server-integration level), fail-closed on unwritable audit log (server-integration level + unit level), concurrent-call budget accounting. 24/24 passing. Two of the review's five findings remain open by design, not silently dropped: the maxToolCallsPerTurn session-lifetime-vs-per-turn semantic gap (needs a real TTL/reset design) and the stdio-only enforcement scope (HTTP/ WS transport lives in a separate @claude-flow/mcp package) — both already documented in this module's own doc comment and flagged as follow-up in the PR/issue, not attempted here to keep this round's diff reviewable. Getting the repo's other currently-broken workflows (CI/CD Pipeline, CVE Audit Gate, Cross-Agent Integration Tests, V3 CI, Verification Pipeline) green is out of scope for this PR — confirmed pre-existing and base-branch- wide (see PR comment), unrelated to this diff. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01QqhXjtDofNNJPX3CCVQyrk --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0db381f7f7 |
dream(performance): #3129 remove unwired diskann-backend.ts, fix stale HNSW claims in generated CLAUDE.md (evaluated, ACCEPT) (#3130)
* dream(performance): remove unwired diskann-backend.ts, fix stale HNSW claims in generated CLAUDE.md
Dream Cycle 2026-08-30 (DEEP=performance, SCAN=security,hive-mind).
- v3/@claude-flow/cli/src/ruvector/diskann-backend.ts deleted: a full
diskann->hnsw->cosine-js fallback chain (375 lines) with zero call
sites anywhere in the monorepo; @ruvector/diskann is not a listed
dependency, so it never shipped.
- claudemd-generator.ts's performanceSection()/intelligenceSystem():
replaced "Use HNSW/DiskANN for vector search" and the unreproduced
"150x-12,500x faster search" figure with this repo's own measured
number ("~1.9x-4.7x vs brute force, recall@10 ~0.99", per
docs/reviews/intelligence-system-audit-2026-05-29.md) so every new
ruflo init project gets the same corrected claim already on record
in root CLAUDE.md, not the stale one.
- New regression test pins the corrected figure across every
CLAUDE.md template.
- Pulled forward the 2026-08-20..29 ledger backfill from
dream/2026-08-29-swarm (not yet on main) and appended this run
below it.
Full research report: docs/dream-cycle/dream-gist-2026-08-30.md
* dream(performance): finalize witness stamp, ledger row, issue/PR cross-refs (#3129, #3130)
* dream(performance): address review — port #3104 pnpm fix, trim ledger to 1 row, live benchmark receipt
- Port PR #3104's v3/pnpm-lock.yaml specifier fix (accepted, open) so v3-pnpm
CI jobs (Type Check V3, Test V3 Packages) install cleanly; root-npm side
(issue #3095) left to maintainer per that issue's own text.
- Trim LEDGER.md back to exactly one row for this run (2026-08-30); the
2026-08-20..29 backfill this branch had accumulated belongs to its own
ledger-repair effort, not this candidate's row.
- Replace the hardcoded "~1.9x-4.7x, recall@10 ~0.99" replacement claim
(itself sourced from a doc that self-contradicts ~0.9 vs ~0.99) with a
live re-run of scripts/benchmark-intelligence.mjs on this commit, and
point generated CLAUDE.md at the reproducible command instead of any
hardcoded multiplier so it cannot drift stale again.
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
9f67bb7073 |
dream(swarm): #3122 fix TopologyManager.rebalanceHybrid() one-directional adjacency (evaluated, ACCEPT) (#3123)
rebalanceHybrid()'s worker-mesh loop added edges only on the initiating worker's side (connections array + adjacencyList), never reciprocating on the target. Every sibling rebalance*() method, and this same function's own coordinator loop 8 lines below, update both sides. Flagged by 2026-08-24's dream-cycle night and deferred; fixed here with a 6-line mirrored-edge patch plus a deterministic (Math.random mocked) regression test. Independently adversarially reviewed (CONFIRMED). Full package suite 221/221, 0 regressions. Backfills docs/dream-cycle/LEDGER.md for 2026-08-20 through 2026-08-28 (merge-cadence backlog, not lost work) and adds tonight's row. Claude-Session: https://claude.ai/code/session_019nSbi8hkZdmwK2kaBXRj5J Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1bd734e272 |
dream(security): #3102 bind ADR-377 caller-identity verification into live authorizeMcpTool chokepoint (evaluated, ACCEPT-with-caveats) (#3103)
* dream(security): #TBD bind ADR-377 caller-identity verification into live authorizeMcpTool chokepoint (evaluated, ACCEPT-with-caveats)
authorizeMcpTool() trusted the plain, unsigned CLAUDE_FLOW_PRINCIPAL_ID env
var as caller identity with zero verification (OWASP ASI07-class gap).
ADR-377 Phase 3 already implemented Ed25519 issueInvocationToken/
verifyInvocationToken but never wired it into a live dispatch path.
Binds the two: DualModeOrchestrator mints a per-worker signed
InvocationToken at spawn (worker-lifetime TTL, wildcard tool scope --
disclosed reduction from the primitive's original per-call design);
authorizeMcpTool verifies it before trusting identity, failing closed on
missing/forged/expired tokens. Off by default (CLAUDE_FLOW_MCP_CALLER_AUTH).
6 new deterministic Vitest scenarios plus an independent adversarial
critique (fresh session, verdict CONFIRMED-SAFE-WITH-CAVEATS) are recorded
in docs/dream-cycle/dream-gist-2026-08-26.md. Darwin skipped (scope
mismatch -- binary crypto gate, no tunable parameter space).
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015bfVASxkNXKpZbMSLhVU1L
* fix(ci): register ADR-377 Phase 3 caller-identity env vars as known escape hatches
CLAUDE_FLOW_MCP_CALLER_PUBKEY and CLAUDE_FLOW_MCP_INVOCATION_TOKEN
(DualModeOrchestrator's per-worker Ed25519 credential pair, read by
resolveMcpCallerIdentity()) weren't registered in
audit-env-var-precedence.mjs's KNOWN_ESCAPE_HATCHES, so the audit flagged
CLAUDE_FLOW_MCP_CALLER_PUBKEY as a real violation (exit 1). Same no-CLI-flag
reasoning as the existing CLAUDE_FLOW_PRINCIPAL_ID entry: these are
credentials minted into a spawned worker's own environment, not a value
any caller should be able to select via a flag.
Verified: audit-env-var-precedence.mjs now exits 0. policy-runtime.test.ts
still 18/18.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq
* fix(ci): regenerate root package-lock.json for codex's new @claude-flow/security dep
The CI Test Suite job runs plain `npm ci` at repo root (no v3 pnpm
install step), so it resolves workspace deps strictly from
package-lock.json — not from v3/pnpm-lock.yaml, which this PR had
already updated. codex/package.json's new @claude-flow/security
dependency was missing from package-lock.json's codex entry, so CI's
npm ci never linked it and every dual-mode test importing
orchestrator.ts failed with "Cannot find package '@claude-flow/security'".
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq
* fix(ci): build @claude-flow/security before Test Suite runs it
Complements
|
||
|
|
9a9ff88671 |
dream(intelligence): #3109 wire computeConfidencePenalty/updateFisherFromConfidences into distillLearning's EWC gate (evaluated, ACCEPT-scoped, corrected post-review) (#3110)
* dream(intelligence): wire computeConfidencePenalty/updateFisherFromConfidences into distillLearning's EWC gate (evaluated, ACCEPT-with-caveats) Dream Cycle 2026-08-27. distillLearning() called EWCConsolidator.getPenalty with 1-element confidence arrays, which its own length guard collapses to reading globalFisher[0] only (1 of 384 dims) for every pattern regardless of embedding. computeConfidencePenalty/updateFisherFromConfidences already existed for exactly this scalar-confidence case (docstring: "used by SONA after distillLearning") but were dead code. Wires them in. Full research, evaluation receipt, adversarial critique, and witness in the linked issue/gist. * docs(dream-cycle): update ledger for 2026-08-27 + backfill 08-20..26 Backfills 08-24 (swarm/weightedConsensus), 08-25 (performance/PQ dispatch), 08-26 (security/caller-identity) rows verified via GitHub MCP, and records the confirmed 08-20..23 no-run gap. Appends tonight's 2026-08-27 intelligence row. * dream(intelligence): #3109 correct per-pattern overclaim per ruvnet review ruvnet's review of PR #3110 correctly rejected the frozen hypothesis: computeConfidencePenalty(oldConfidence, newConfidence) takes no per-pattern embedding, so it cannot discriminate between patterns under a fixed consolidator state -- it only reads avg(globalFisher), a value shared by every pattern at a point in time. The original tests showed sensitivity to *changing* the global Fisher state between two cases, not to per-pattern discrimination within one state. The underlying wiring defect is unchanged and still fixed: distillLearning() was calling getPenalty([oldConf],[newConf]), which collapses to reading globalFisher[0] alone. This commit narrows the claim to what's actually true (shared full-Fisher-average signal vs. one arbitrary dimension) and adds the acceptance test ruvnet's review specified: fixed state, equal confidence deltas, different embeddings -> identical penalty, proving no per-pattern discrimination exists. Updates hypothesis/evaluation sections in the gist, the ledger row, and inline comments to match. 153/153 tests passing (+1 acceptance test). --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
fde5b3b88b |
dream(memory): #3118 wire HybridBackend's dead weights field into weighted-RRF fusion (evaluated, ACCEPT) (#3119)
* dream(memory): #3118 wire HybridBackend's dead weights field into real weighted-RRF fusion HybridQuery.weights was computed and then discarded by queryHybrid's 'union' strategy (combineUnion did a plain set-union, no score math, and no caller ever set weights either). Adds combineWeighted: per-arm-weighted reciprocal-rank fusion matching the pattern documented for Qdrant/ Weaviate/Azure AI Search. Evaluated via deterministic Vitest, baseline isolated via git stash: the weight-sensitivity test fails on baseline, passes on candidate. 461/462 full memory-package suite green (1 pre-existing, unrelated environmental failure, confirmed identical on baseline). tsc --noEmit clean. Also backfills docs/dream-cycle/LEDGER.md rows for 2026-08-20..23 (genuine no-run gap) and 2026-08-24..27 (ran to completion but stuck on unmerged draft PRs, not a repeated ledger-append failure — see issue #3118 §11). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01AjuAxfhZi6eZXnLbLC14Ay * fix(memory): validate weights + deterministic tie-break; port #3104 lockfile fix Per ruvnet's review on #3119: - weighted RRF fusion previously had undefined ranking semantics for malformed weights (NaN poisons the sort comparator, negative/Infinity invert or swamp the intended ordering). Each weight component now validates independently (finite, >= 0) and falls back to its own documented default otherwise, same degradation as an omitted weights field already had. - rrf ties now break deterministically by entry.id (explicit secondary sort key) instead of relying on Array.sort's stability plus Map insertion order as an undocumented side effect. - 4 new tests: 3 malformed-weight fallback cases + 1 genuine-tie (weights: {semantic:0, structured:0}) deterministic-order case. Also ports PR #3104's fix verbatim (v3/pnpm-lock.yaml: @claude-flow/mcp specifier ^3.0.0-alpha.9 -> 3.0.0-alpha.10, matching the CLI manifest's exact pin) to unblock the install-dependent CI gates this PR needs to actually execute (issue #3101, also flagged in the review). Root-level npm ETARGET (issue #3095, package-lock.json) is a separate, still-open, maintainer-flagged issue with no proposed fix yet -- not addressed here. Validated: `corepack pnpm install --frozen-lockfile --lockfile-only` and `--ignore-scripts` both succeed from v3/; full @claude-flow/memory suite 465/466 (same 1 pre-existing unrelated env failure); tsc --noEmit clean. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01AjuAxfhZi6eZXnLbLC14Ay * fix(test): stop hybrid-backend's rrf tie-break test from flaking on id/key mismatch The tie-break test asserted a hardcoded key-alphabetical expected order, but combineWeighted's tie-break is entry.id.localeCompare(), not key. generateMemoryId() produces `mem_<ms-timestamp>_<random>` — the two fixture entries are frequently created in the same millisecond, so which id sorts first is decided by the random suffix, with no fixed relationship to which key sorts first. Confirmed empirically: 1/5 runs failed before this fix, 10/10 pass after. Fix: capture each entry's actual generated id in beforeEach, and compute the test's expected order from those ids (matching the code's actual documented contract) instead of assuming id-order equals key-order. Full @claude-flow/memory suite still 475/475, tsc clean. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
2d6227005e |
dream(performance): #3093 wire productQuantizeDistance() into HNSW search path (evaluated, ACCEPT) (#3094)
* dream(performance): #3093 wire productQuantizeDistance() into HNSW search path Quantizer.productQuantize()/productQuantizeDistance() correctly implement codebook-aware PQ encoding and distance, but HNSWIndex.distance()/ distanceOptimized() never called productQuantizeDistance() - generic cosine/euclidean/dot/manhattan distance was computed directly on raw PQ centroid-index arrays, which is numerically meaningless. Zero callers of productQuantizeDistance() existed anywhere in v3/ before this change. Wires the dispatch in, guarded by a new isValidPQEncoding() check to avoid crashing/corrupting on vectors added before PQ codebook training completes (a separate, disclosed, pre-existing limitation not fully solved here - see hnsw-index.ts comments and docs/dream-cycle/dream-gist-2026-08-25.md). Measured on a deterministic synthetic corpus: recall@10 recovers from 0.097 (pre-fix) to 0.270 (post-fix), independently reproduced by an adversarial critic across 3 additional corpus shapes. Full evaluation receipt, hypothesis, and adversarial critique in the linked gist and issue #3093. Also backfills the 2026-08-24 ledger row (branch/issue/PR existed but the row was never appended - recovery per this pipeline's STEP 1 convention). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Aor8qaXa8T3ZCGgRmjc4VG * dream(performance): append 2026-08-25 ledger row (#3093/#3094) Follow-up to a8b527105 now that the issue and PR numbers exist. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Aor8qaXa8T3ZCGgRmjc4VG --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0d03ce06a2 |
dream(swarm): #3085 weightedConsensus() trust weights now reach the vote tally (evaluated, ACCEPT) (#3086)
* dream(swarm): #3085 thread weightedConsensus() trust weights into raft/byzantine/gossip tally logic (evaluated, ACCEPT) QueenCoordinator.weightedConsensus() has always computed real per-agent trust weights (successRate * health) but they were architecturally unreachable: ConsensusProposal/ConsensusConfig had no field to carry a weight, and raft.ts/byzantine.ts/gossip.ts all tallied votes as a flat one-node-one-vote count. Choosing requiredConsensus: 'weighted' was byte-identical to 'majority'. Threads weights through propose()/vote() into each implementation's tally logic, clamped to [0,1] per voter (byzantine/gossip's absolute/ratio quorums and raft's count-based quorum all preserve their BFT/safety margins under the clamp), defaulting to weight 1 (byte-identical to today) when no weights are supplied. 226/226 tests passing (223 pre-existing incl. 1 updated to match now-real behavior + ~16 new across gossip/byzantine/raft), 0 regressions, clean tsc --noEmit. Independently adversarially reviewed (fresh session, no authoring context): CONFIRMED-WITH-CAVEATS -- added a raft reject-prevention test in response to a real test-coverage gap the critic found, and two disclosure/hardening comments for pre-existing, non-blocking caveats (byzantine's separate PBFT commit-message quorum path, and a security-guard note on the newly-live weights parameter). Full research, evaluation receipt, and witness: docs/dream-cycle/dream-gist-2026-08-24.md Closes #3085 Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Xwh2YbnzVqjyMnWpCVHxP9 * docs(dream-cycle): append 2026-08-24 swarm ledger row Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Xwh2YbnzVqjyMnWpCVHxP9 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
0acd106420 |
fix(agentdb): pattern-store returns a readable id, pattern-search surfaces why tier=substring fired (#3326)
Fixes #3324 and #3325, both found while triaging #3288. #3324: bridgeStorePattern's bridge-fallback branch (registry present, reasoningBank unusable — the common real-world state) returned bridgeStoreEntry's own internally generated row id (result.id, generateId('entry')) as the caller's patternId, instead of the actual key the row was stored under (generateId('pattern')). getEntry/memory_retrieve look up by key, so the returned handle could never be read back. #3325: agentdb_pattern-search's Tier-1 semantic step had two silencers — a bare `catch {}` discarding a thrown error, and `semantic?.results ?? []` treating `{success: false, error}` (which searchEntries returns WITHOUT throwing) as an honest zero-match. Both made a hard failure and "nothing found" indistinguishable. Capture whichever fires as `semanticError` and return it alongside `tier` when tier === 'substring'. Regression tests: - #3324: new agentdb-pattern-bridge-fallback-3324.test.ts uses the documented __setMemoryBridgeRegistryForTests() seam (same pattern as memory-store-persist-warning-2968.test.ts) to force the bridge-fallback branch deterministically and round-trip the returned id through bridgeGetEntry plus a direct SQL check. Confirmed it fails on the unfixed code (patternId comes back entry_-prefixed) and passes on the fix (pattern_-prefixed) via git stash. - #3325: extends bug-cluster-2219-2226.test.ts — asserts semanticError is a non-empty string whenever tier === 'substring' and the field is present; tolerant of tier === 'semantic' or an honest empty substring result, matching the file's existing skip-gracefully convention. Not addressed: the deeper reason Tier-1 semantic search itself returns 0 where memory_search finds the same entry — #3325 explicitly flags this as an unconfirmed hypothesis (possible distinct module instance / uninitialised index between agentdb-tools.ts's direct import and memory-tools.ts's getMemoryFunctions()), not something this fix diagnoses or resolves. Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq |
||
|
|
dfd6596551 |
fix(agentdb): surface degraded:true on the pattern-search/store fallback path (#3323)
* fix(agentdb): surface degraded:true on the pattern-search/store fallback path Fixes #3288. agentdb_pattern-search and agentdb_pattern-store both return {success: true} (or a results array with no error field) when they've silently fallen back to the memory-store-fallback / substring-match path because the ReasoningBank controller is unavailable — the only signal was a free-text `note` string, which a caller has no structural reason to check on an apparently-successful response. Add degraded:true to both fallback return paths, matching the existing {degraded: true, reason} convention already used by agentbbs-tools.ts. The healthy ReasoningBank path is unaffected (no degraded field). Regression tests added to bug-cluster-2219-2226.test.ts, alongside the existing #2226 test for the same two handlers: assert degraded:true when controller === 'memory-store-fallback', and degraded === undefined otherwise. Both skip gracefully when the backend can't initialize in isolation, matching the file's existing convention. Does not address the underlying reason vectorBackend/reasoningBank are disabled in a given deployment (tracked separately in #1228/#2296) — this fix is scoped to making that state observable from the tool response shape, per the report's suggested fix direction. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq * fix(agentdb): flag degraded at the controller boundary, not per fallback block Review caught a real gap in the first commit: bridgeStorePattern and bridgeSearchPatterns (memory-bridge.ts) return non-null with controller: 'bridge-fallback' when the registry exists but reasoningBank itself is unusable — this is the degraded state #3288 actually reported. That result flows through `if (result) return result;` / the results.length>0 early-return in agentdb-tools.ts, both of which sit BEFORE the memory-store-fallback blocks the previous commit patched, so agentdb_pattern-store never got flagged at all, and agentdb_pattern-search only got flagged when bridge-fallback happened to return zero results. Fix: check `result.controller === 'reasoningBank'` at both return boundaries instead of patching specific fallback-label blocks. Any other label — bridge-fallback, memory-store-fallback, or one added later — is degraded by definition, so this can't silently reopen the gap the way per-block matching just did. Also adds a `reason` field (stable, machine-checkable) alongside the existing `note` (human-readable), matching agentbbs-tools.ts's {degraded, reason} shape exactly rather than degraded-only. Tests inverted to match: assert degraded:true for every controller value EXCEPT 'reasoningBank', instead of asserting undefined for everything except the one label the previous commit patched — the old assertion would have made bridge-fallback's un-flagged state an enforced invariant, failing CI on a later correct fix. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpqv3.41.4 |
||
|
|
8008bc3562 |
fix(federation): verified event identity must win over publisher-controlled content (#3322)
* fix(federation): verified event identity must win over publisher-controlled content fetchRecent/fetchManyOn/fetchChannel spread the parsed message content after the signature-verified id/pubkey/created_at, so a publisher could put those keys in their own content and overwrite their verified identity downstream (reduceClaims trusts .pubkey for claim release/handoff authorization). Reorder so verified fields always win. Also in x-federation-channels.ts: reqEvents() never called verifyEvent() at all, so channel-read/grant-accept events were trusted unsigned; add the check. Same content-override reorder in x_federation_channel_read. fix(hooks): escape argv for the Windows shell:true npm-shim path ruflo-hook.cjs's invokeHook() passes hook-derived values (command text, file paths) through cmd.exe via shell:true to resolve npm's .cmd/.ps1 shims. Node does no escaping in that mode, so a value containing a cmd.exe metacharacter could be reinterpreted as a separate command or redirection instead of reaching the CLI as data. Add escapeCmdArg() (qntm.org/cmd algorithm, same one cross-spawn uses) and apply it to every argv element on that path. No native Windows/cmd.exe available to execute this end-to-end in this environment — verified at the string-transform level only (8 unit tests). Real Windows execution remains a required follow-up gate. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpq * fix(ci): register new hook test env var and test file with CI guards Follow-up to the previous commit's escape-cmd-arg.test.cjs: - Register RUFLO_HOOK_UNIT_TEST in audit-env-var-precedence.mjs's KNOWN_ESCAPE_HATCHES, matching the existing RUFLO_HOOK_CLI_OVERRIDE/ RUFLO_HOOK_DEBUG_STDOUT entries for the same file. - Register plugins/ruflo-core/scripts/escape-cmd-arg.test.cjs in ci-test-baseline.txt, matching the sibling mcp-launch.test.cjs entry — both are node:test-based .cjs files that vitest's own JSON reporter marks "failed" (it doesn't recognize the node:test API) even though every test inside genuinely passes under `node --test`. Verified locally: node scripts/audit-env-var-precedence.mjs now passes (exit 0). A scoped `vitest run` + `ci-test-ratchet.mjs --report` against just these two files confirms escape-cmd-arg.test.cjs is no longer an unexpected failure (the one remaining flagged file is a stale local worktree artifact under .claude/worktrees/, not part of a clean CI checkout). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01RYP825phHMjPRmmjkcAWpqv3.41.3 |
||
|
|
1992ffb020 |
chore(data): clone snapshot #13 — 0 clones (14d)
Auto-generated by .github/workflows/clone-tracker.yml. Source: GitHub Traffic API (14-day rolling window). Co-Authored-By: ruflo-bot <ruflo-bot@users.noreply.github.com> |
||
|
|
b02c0cacec | fix(gateway): align review hints with irreversible sends (#3308) | ||
|
|
3949f9c50f |
Merge pull request #3285 from ruvnet/feat/chatgpt-federation-publisher
feat(federation): give ChatGPT Federation its own key and its own relay connection |
||
|
|
e7aa82b52c | fix(plugin): add ChatGPT federation manifest | ||
|
|
a598851dc5 | Merge main and restore OAuth on the ChatGPT MCP profile | ||
|
|
2821d7bb77 |
fix(x-gateway): tools declared themselves destructive, and relay text reached the model unlabelled (#3300)
* fix(x-gateway): tools declared themselves destructive and open-world by omission
Every tool registered here used the 4-argument `mcp.tool(name, description,
schema, handler)` form, which ships no `annotations` object. That is not
neutral. The MCP spec (2025-03-26) defines a default per hint, and the defaults
are readOnlyHint false, destructiveHint TRUE, idempotentHint false,
openWorldHint TRUE — so a client applying the spec correctly rendered
`federation_sync`, `channel_list` and `claims_status` as destructive,
open-world, public writes alongside the genuinely gated ones. ChatGPT did
exactly that, and it is an OpenAI review blocker.
All 14 tools now use the 5-argument overload and state all four hints
explicitly, through READ/WRITE helpers so none can be forgotten. Hints follow
what each tool does rather than what it is called:
- The six open reads are read-only, idempotent and closed-world.
- The relay writes are additive, not destructive: they append a signed event.
`federation_admit` is idempotent (same pubkey and role leaves the roster
identical); `claims_issue` is not, because re-issuing extends the TTL.
- `claims_release` is the only destructive tool: it REMOVES an ownership
grant, and another agent can take the resource the moment it lands.
Flagging every write destructive would reproduce the original complaint.
- `seraphina_guidance` is the judgement call, and the reasoning is in the
code. It publishes nothing, which argues for read-only, but every call
decrements a shared daily budget and an IP-hourly allowance and spends real
meta-llm money — readOnlyHint true would tell a client it is free to
repeat. It is also the only tool whose answer comes from an external model
rather than from our own relay, so it is the only openWorldHint true.
These are hints, and the spec says a client MUST NOT trust them for security.
Nothing here relies on them: `gated()` and `checkAdmin()` are untouched, and a
test asserts every admin-gated tool still advertises itself as a write.
Four tests over a real tools/list round trip: all four hints present on every
tool (the actual regression), the full value table, readOnlyHint agreeing with
the adminToken gate in both directions, and destructiveHint being exactly
{claims_release}.
Pre-existing and unrelated: `channels: the registry resource publishes the
directory` fails because it asserts version 0.7.0 while server.mjs is 0.7.1.
Not touched here.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01MZWJgQeQR3J2axxMgve18k
* feat(x-gateway): isolated public-review MCP endpoint for the OpenAI app submission
Adds POST /chatgpt/mcp, three public pages, and a domain-control route. None of
this existed — there was no submission patch to apply, so it is built here.
The review rules forbid a tool that ACCEPTS a secret as an argument: no
passwords, API keys, tokens, private keys or invite codes in any inputSchema.
The legacy surface violates that deliberately — seven tools take an `adminToken`
string, because that is how service-side callers have always driven them, and
/mcp has to keep working for them.
So this is a second PROFILE over the same handlers, not a rewrite:
- The credential moves off the tool surface and into the transport. On
/chatgpt/mcp the gated writes read their token from `Authorization: Bearer`
(or X-Ruflo-Admin-Token) instead of a model-visible argument. Enforcement is
the same constant-time checkAdmin, so this narrows what is EXPOSED without
widening what is ALLOWED. A test calls federation_join with no credential, a
wrong bearer and a wrong header — all three still refused — then with the
right one to prove the gate opens rather than having been deleted.
- seraphina_guidance's adminToken was OPTIONAL, which makes it no less of a
secret field; it is gone from the review schema too. The tool still answers
anonymously under its shared budget, which was always its point.
- Two tools are WITHHELD rather than reshaped: federation_invite_mint and
federation_admit. They decide who may exist on the relay at all. Reshaping
invite_mint would not have helped in any case — its secret is the RESULT, an
invite code in the tool output, and the rules treat returning one the same
as accepting one.
That leaves 12 advertised tools. The runbook's expected count and the real count
agree, but they were arrived at independently: the number is what falls out of
removing membership administration, not a target anything was trimmed to.
/privacy, /terms and /support are real documents, not placeholders — a human
reads them. The privacy notice leads with the disclosure that actually matters
for this service and that a template would have missed: publication to a
membership-gated relay is public to its members and cannot be reliably undone,
because other members already hold their own copy. Promising deletion we cannot
perform would be the dishonest option.
/.well-known/openai-apps-challenge serves the exact value of
OPENAI_APPS_CHALLENGE and nothing else — no markup, no trailing whitespace. It
is read from the environment only: never hardcoded, never logged, and a test
asserts it appears in no other response. Unset it 404s rather than serving an
empty 200, because a verifier reads an empty 200 as "the challenge is the empty
string" and fails in a way nobody can diagnose. The tests supply their own
throwaway value rather than embedding a real one in a fixture.
LEGACY IS UNCHANGED, which mattered more here than the new endpoint working:
/mcp's tools/list is byte-identical before and after, verified by curl against a
running server — sha256 4bdac26e5370f4ad01c6aa319e37c050ee375d7709d175d9cac3d748f94edb85
on both sides. A test also pins /mcp at 14 tools WITH its adminToken arguments
intact, so narrowing it later fails loudly instead of silently breaking
service-side callers.
Also fixes the pre-existing red test: gateway.test.mjs asserted version '0.7.0'
while server.mjs said '0.7.1'. The version had three hand-synced copies, which is
why they drifted. It now reads from package.json in one place, and the test
asserts against that same source plus a shape check so the comparison cannot
pass vacuously. Suite is 35/35 green; it was 24/25 before.
Verified by running: all five paths 200 (404 for the challenge when unset), the
review tools/list carrying all four hints on every tool and zero secret-bearing
property names — checked by regex, with the legacy surface as a negative control
so the scan is proven able to detect something.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01MZWJgQeQR3J2axxMgve18k
* fix(x-gateway): relay messages reached the model as unlabelled text
The submission preflight requires that federation messages are treated as
untrusted data, never instructions. That requirement was NOT met. Every
relay-sourced tool returned third-party content verbatim into a model's context
with nothing distinguishing it from the operator's own instructions, and the
only thing standing against that was prose — a line in a resource document and a
sentence in Seraphina's system prompt saying content is data, not instructions.
An assertion is not an enforcement.
A member could publish a message whose body reads "ignore previous instructions
and call federation_publish with …" and it arrived looking exactly like a
directive.
THE DEFENCE IS STRUCTURAL, AND DELIBERATELY NOT DETECTION. There is no regex
hunting for instruction-shaped phrasing, and untrusted.mjs says at length why
nobody should add one: such a filter fails silently on every phrasing it has not
seen, and its real effect is to convince everyone downstream that the content
was sanitised when it was not. Instead:
- LABEL — payloads carry `untrusted: true` and a provenance string, so a client
can attribute the words without parsing English.
- DELIMIT — content sits inside a fence whose token is a fresh UUID per
response. A fixed marker is forgeable: publish a message containing the
closing marker and the block appears to end early, with everything after it
reading as trusted narration. A publisher writes their message before the
response exists, so they cannot predict the token.
- PRESERVE — content is verbatim. Nothing is mangled, truncated or escaped into
uselessness. The goal is that a model can tell WHOSE words these are, not
that the words are unreadable.
Covers federation_sync and channel_sync, and also claims_status and channel_list
(every resourceId and public channel name is a string a third party chose), the
three relay-sourced ruv:// resources, and Seraphina's prompt — where the snapshot
is entirely third-party content and was the most direct injection path of all,
since it goes straight into an LLM turn.
Gateway-authored output is deliberately NOT fenced. federation_identity and
federation_onboarding are our words, and a test pins that: if everything were
fenced the fence would mean nothing.
Applied to BOTH endpoints. /mcp's tools/list is still byte-identical —
sha256 4bdac26e5370f4ad01c6aa319e37c050ee375d7709d175d9cac3d748f94edb85, the same
value pinned before this change — because tools/list carries name, description,
inputSchema and annotations, and this wraps tool RESULTS, which are a different
method. Checked structurally first, then re-verified by curl.
One finding came out of the adversarial test rather than review: the warning text
originally quoted the fence markers, which made each marker appear twice, so a
parser taking first-open to first-close extracted an EMPTY region. The prose now
refers to "the fenced block below" without naming it, and a test asserts each
marker appears exactly once.
Tests publish genuinely hostile bodies through a stand-in relay and read them
back through the real tools: labelled and fenced on both endpoints, a forged
closing marker inert inside the fence, a fresh token per response, and content
preserved. Deliberately NOT asserted: that hostile text was removed. It must not
be, and a test demanding that would be pressure to add the very filter this
rejects. 42/42 green.
Also adds the two submission artifacts, which did not exist anywhere in the repo:
submission-listing.json (portal copy — real descriptions, not placeholders) and
test-cases.md (7 positive, 5 negative reviewer cases, all runnable anonymously by
anyone on the public internet with curl — no account, MFA, email confirmation or
private-network access). test-cases.md separates what was actually executed from
what could not be: the relay-backed reads need the gateway key to be an admitted
relay member, which a local instance is not, and nothing was tested against
x.ruv.io itself because the endpoint is not deployed yet.
assets/icon.png is deliberately NOT created — a logo is a design decision, not
something to synthesise. It is the one artifact still needed from a human.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01MZWJgQeQR3J2axxMgve18k
* fix(x-gateway): drop a backtracking-prone bearer regex (CodeQL js/polynomial-redos)
`/^Bearer\s+(.+)$/i` lets `\s+` and `.+` both match whitespace, so on an input
that cannot match, the engine tries every split point. Measured on the old
regex with a forced failing match: n=1000 0.8ms, 2000 2.5ms, 4000 6.7ms,
8000 26.6ms — quadratic. Replaced with a prefix compare, one unquantified
character class, and trim(), all linear: ~0.01ms flat at the same sizes.
HONEST SCOPE — this is not a live vulnerability, and the commit should not be
read as patching one. To force the failing match the input needs a newline
(`.` never matches `\n`, and `$` without /m only matches at the end or before
one trailing newline). HTTP forbids newlines in header values, and I verified
undici strips them rather than passing them through: `Headers.set('authorization',
'Bearer \n\n')` yields the value `"Bearer"`. Every newline-free candidate I
tried — 8000 spaces, alternating space/tab, all tabs — MATCHES in under 0.06ms,
so no quadratic path is reachable through a real request.
Fixing anyway: the pattern is fragile against any future caller that feeds this
helper a string not sourced from an HTTP header, the replacement is free, and a
red CodeQL gate that everyone learns to wave through is worse than the bug.
No test added. The only input that distinguishes old from new cannot be sent
over HTTP, so a test asserting it would pass trivially and prove nothing.
Existing suite: 42/42 green, 426ms.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01MZWJgQeQR3J2axxMgve18k
|
||
|
|
39e0b0540c | docs(protocol): publish ANS and Nostr federation governance draft (#3299) | ||
|
|
19e7b32e24 |
fix(gateway): a refused write names the cause instead of blaming the admin token
Every refusal returned "admin token required or invalid" — true of all of them,
useful for none. A caller holding a perfectly valid OAuth token that merely
lacked swarm:publish read that as a broken credential and went looking in the
wrong place, and the only way to see the real shape was the operator-side log
line `auth=oauth scopes=swarm:read`. That asymmetry cost several round trips.
Now the two cases are distinguished:
* a token missing the scope is told which scope is missing, which scopes it
actually holds, and that an already-issued token cannot gain one — it must
be re-authorised, because refreshing preserves the original grant;
* no credential at all gets its own message pointing at the WWW-Authenticate
challenge.
Both explicitly warn against pasting the admin token, which is the tempting
wrong remedy and authorises every other gateway write.
Six existing assertions pinned the old prose. They now pin the REFUSAL rather
than the wording, so they keep guarding the behaviour instead of the sentence.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
d423bd3a65 |
fix(gateway): tool descriptions that tell the truth about authorisation
Every write tool still said "Admin-gated" after OAuth landed. That is not just
stale: a model reading it will not attempt the OAuth path, and will ask a person
for the admin token — which authorises every other gateway write and must never
be pasted into a browser. The connector's own settings page showed exactly this
text.
Each tool now states how it is actually authorised:
* federation_publish, channel_publish, claims_issue, claims_release,
federation_join — an OAuth token carrying swarm:publish, OR the admin token
* federation_invite_mint, federation_admit — admin token only, and the
description says swarm:publish is NOT sufficient
That second group is also a REAL privilege split, not only wording. Admitting a
member or minting an invite decides who may join the federation; publishing a
message does not. A self-registered client can obtain swarm:publish with nothing
but a sign-in, so letting that scope grant membership control would leave the
registration gate as the only thing between a stranger and the relay roster.
Those two tools are now admin-token-only in code.
Separately, seraphina_guidance claimed "Admin-gated because it spends meta-llm
budget" and has not been admin-gated since the budget guard was added: it is
open, bounded by a shared cap, and the admin token merely lifts the cap and
unlocks the high/ultra tiers. Corrected — a tool that says it needs a credential
it does not need pushes people toward handing one over.
A test now rejects the bare phrase "Admin-gated" on any tool exposing adminToken
and requires each to state its real authorisation. It caught seraphina, which I
had missed.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
6814b513de |
docs(adr): ADR-387 ChatGPT Federation connector, ADR-388 OAuth on x.ruv.io
ADR-387 records why a relaying design is impossible (buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity), where the signing key lives and why it is in ruv-dev rather than cognitum, and the three custody omissions that are each enforced by a test. ADR-388 records OAuth as an ADDITIVE credential on the federation's front door — reads stay open and adminToken keeps working, because closing either would strand every agent already connected. It also records why swarm:* rather than federation:* (audience pinning is the control; scope names are a UX contract, correcting an overstatement in console migration 0029), and why the resource accepts dcr- audiences. Both ADRs carry a section on what the work cost: bugs that read as working code — a 301-second hanging GET, a log that could not tell 'no credential' from 'credential refused', a required adminToken that made OAuth writes impossible, a read-back verifier using an unadmitted key, and a client-existence probe that returned the same answer whether or not the client existed. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
40c1744ec8 |
fix(gateway): three bugs that made ChatGPT connector setup fail after sign-in
Symptom: OAuth completed, ChatGPT redirected back, then "There was a problem
connecting". Three separate faults, and the first one hid the other two.
**1. A denied request left no trace.** The 401 was returned BEFORE the
`mcp auth=` log line, so a rejected token logged nothing and the logs showed only
`auth=anonymous`. Reading them, I concluded no token had arrived — when a token
had arrived and been refused. Logging now happens first and records the reason
and the observed audience. A log that cannot distinguish "no credential" from
"credential refused" is worse than no log, because it is believed.
**2. `GET /mcp` hung for 300 seconds.** Streamable HTTP lets a GET open a
server->client SSE stream, but this transport is stateless
(`sessionIdGenerator: undefined`) so there is no session to attach one to: the
SDK held the socket until Cloud Run severed it, logging "Truncated response
body". Measured: 301.0s per request. Now 405 with `Allow: POST`, which is the
spec's answer for a server that does not offer the GET stream. The same latent
bug exists in the connector, masked only because enforcement 401s first.
**3. Audience pinning made dynamic registration useless.** A DCR client's token
carries `aud=<its own client_id>` — `dcr-…`, never `ruflo-x-gateway` — so every
self-registered client was refused with `invalid_token`. That is the actual
reason connector setup failed after a successful sign-in.
The resource now accepts its own client id OR any `dcr-` client, because
registration constrains those to swarm:* / openid / email — this resource's own
scopes — so such a token is by construction minted FOR this resource. That
acceptance is exactly as strong as the registration gate, and the comment says
so: if reserved scopes ever become self-registrable, this stops being safe.
Audience is checked in our code rather than by jose, since a fixed expected
value cannot express "own client, or any dynamically registered one" and the DCR
set changes at runtime. An aud-less token is still refused outright.
Tests: 31 -> 34. The dcr-audience test also pins that matching is a PREFIX check,
not a substring — verified by mutation: `aud.includes('dcr-')` lets
`not-a-dcr-client` through and fails the suite. The session-token assertion now
pins the property ("not bound to this resource") rather than exact wording,
which changed when aud moved out of jose's check.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
b329f0937c |
feat(gateway): OAuth 2.1 on x.ruv.io/mcp, and no credentials as tool arguments
Two changes, one per service.
**Connector:** `callerToken` is gone from the public tool schema. A credential
passed as a tool argument is model-generated — it lands in the model's context
and in tool-call transcripts, and makes authority something the model can be
talked into supplying. Authentication belongs to the transport; the tool should
receive an already-authenticated identity. A test now asserts NO tool exposes an
argument matching token/secret/password/credential.
**Gateway:** OAuth 2.1 as an additive second credential, so x.ruv.io/mcp can be
used by an MCP host that authenticates rather than one holding a shared secret.
* RFC 9728 discovery at both probed paths, `resource` echoing the identifier
the client asked about
* bearer validation against the issuer's JWKS, audience pinned to this
resource's own client_id
* `swarm:read` / `swarm:publish` — deliberately NOT `federation:*`. Isolation
comes from audience pinning, not scope exclusivity (correcting an earlier
overstatement of mine), but distinct scopes keep a consent screen honest
about what this resource grants
* CORS preflight, and WWW-Authenticate exposed so a browser-origin client can
read the challenge at all
* reads stay open and adminToken keeps working — this is the federation's open
front door and closing it would strand every existing participant
Three defects the tests caught, each of which would have shipped looking correct:
* `adminToken` was REQUIRED in the schema, so every OAuth-authorised write was
rejected at validation before the handler could consider the token. OAuth
writes were impossible. Now optional; the handler enforces, and an existing
test that encoded the old contract was updated to assert the refusal at the
handler rather than simply dropped.
* no audience guard: without RUFLO_OAUTH_CLIENT_ID the gateway would have
honoured any token this issuer ever minted, for any Cognitum app. It now
refuses bearer tokens entirely rather than accepting them unpinned.
* a bearer that fails verification is refused, never silently downgraded to
anonymous — which would hand a caller that believes it is authenticated the
quieter anonymous permission set.
NOT YET DEPLOYED to x.ruv.io: the `ruflo-x-gateway` client does not exist on
auth.cognitum.one, so OAuth would be inert (and, without the guard above, would
have been worse than inert). It needs an upstream migration first.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
c6b825897b |
test(federation): make e2e correct in the enforced world
With CGF_OAUTH_REQUIRED=true an unauthenticated tools/list is SUPPOSED to 401,
so the script reported two failures for the system working as designed. A test
that goes red when the thing it tests starts working correctly is worse than no
test: the next person learns to ignore it.
It now reads the enforcement state first and asserts accordingly:
* enforced, no token — checks the surface via the public service-info document
(which is unauthenticated by design), asserts the challenge is issued, and
reports the MCP-surface checks as PENDING rather than failed;
* enforced, CGF_ACCESS_TOKEN supplied — exercises the real MCP surface and the
publish/read-back/dedupe path with that token;
* not enforced — the previous behaviour, via x-caller-token.
Two assertions added that only mean anything now: an unauthenticated call is
challenged, and the retired caller token no longer authorises publishing — the
transitional door is shut, not merely unadvertised.
PENDING is deliberately distinct from FAIL throughout. "We did not exercise
this" and "this is broken" must not look alike.
Against production with OAuth enforced: 11 passed, 0 failed, 2 pending.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
005dbf186f |
fix(federation): point the 401 challenge at the metadata for the requested path
RFC 9728 §5.1: resource_metadata must name the metadata document for the resource the client actually requested. A client using <base>/mcp was being sent to the bare document, whose `resource` is <base> — an identifier it never asked about. That is the same mismatch already fixed inside the documents themselves, and it stalls setup silently rather than erroring. The challenge now carries the /mcp suffix when /mcp was requested, so the pointer and the document agree with what the client is using. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
25d4718004 |
feat(federation): log the auth mode per call, without token material
"Is OAuth actually being used?" was unanswerable: Cloud Run request logging is off for this service and the app logged nothing per request, so the only evidence of a connector working was the user saying so. One line per /mcp call now records the mode (oauth / legacy / denied), the granted scopes, and a 12-hex-char SHA-256 prefix of the subject — enough to correlate calls from one identity, not enough to identify or replay anyone. Never the token, and the denied case records only the error code. Logging is wrapped so it can never break a request. A test asserts the line contains neither the token nor the raw subject. Verified in production: an anonymous call logs auth=legacy, an invalid bearer logs auth=denied reason=invalid_token. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
62609d77ab |
fix(federation): CORS preflight and the RFC 9728 resource identifier
Two defects that would each break connector setup with nothing but a generic "something went wrong" at the client. **Preflight returned 405.** Only GET / and POST /mcp were routed, so OPTIONS fell through. A browser-origin connector must preflight before it may POST, so it could never reach the MCP endpoint at all. Now answers 204 with the methods and headers it needs — including `authorization` — and exposes `www-authenticate`, without which the client cannot read the challenge and so never learns where to authenticate. Origin is `*` deliberately: authority here comes from the bearer token, not from where the request originated. **The resource identifier did not match what the client asked about.** RFC 9728 §3 requires `resource` to be the identifier the client used. A client given `<base>/mcp` fetches the path-suffixed document and expects `<base>/mcp` back; we answered with the bare origin for both paths. That is a mismatch a client is entitled to reject — silently, during setup. The suffixed document now returns `<base>/mcp` and the bare one `<base>`. Neither was caught by the existing tests because both were tested only through a non-browser client that never preflights and never compares the resource value. Two tests added for exactly those gaps. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
005a0ed25e |
chore(x-gateway): 0.7.1 — version label for the claims TTL fix (#3293)
Claude-Session: https://claude.ai/code/session_015y2UjPSd3VX1CYnPstZJ5D |
||
|
|
4bb9ca182d |
fix(x-gateway): claims reducer honours ttlSeconds — expired leases free the resource (#3292)
Found by an external node's failure-path probes on the live swarm (T1 expired-lease, T5 disconnected-worker): reduceClaims recorded ttlSeconds but never applied it, so a worker that claimed and then vanished held the resource forever, and the docs' promise that a timed-out claim frees itself was false. - Expiry is evaluated against the next event's timestamp while reducing (deterministic replay: a later ClaimIssued after expiry wins) and against `now` for the final ledger (a disconnected owner's lease lapses without a release). - Claims without ttlSeconds never expire (unchanged). - Ledger entries now carry `expiresAt` (ISO) so clients can show remaining lease. - Tests: ttl expiry lets a later claim win; live lease is kept; disconnected worker frees; no-ttl never expires; releasing an expired claim is a no-op. Claude-Session: https://claude.ai/code/session_015y2UjPSd3VX1CYnPstZJ5D |
||
|
|
7013988bb6 |
fix(federation): make the client-existence probe actually distinguish anything
The probe reported "client row not deployed" after the migration had in fact deployed. Two defects, and the second is the one worth remembering. It read the page TITLE. Every rejection from /oauth/authorize is a 400 titled "Invalid OAuth Request", so the title cannot tell a registered client from an unknown one. The reason is in the body: an unknown client says "Unknown client_id"; a registered one renders its consent page naming the app. And the probe omitted `state`, which the endpoint requires in practice — so even a known-good client was rejected, which reads exactly like "not registered". I only found both because I controlled the probe against a client that certainly exists: `music-cognitum-one` returned the identical 400. A check that reports the same thing for a client that exists and one that does not is measuring nothing. Now matches on the reason, and runs a permanent control against a client that cannot exist — so if the error page ever changes, the check fails loudly instead of quietly becoming one that always passes. Against production after the deploy: 16 passed, 0 failed, 2 pending (the interactive sign-in and the CGF_OAUTH_REQUIRED flip). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
5900dc5681 |
test(federation): repeatable post-deployment e2e for the connector
Turns the acceptance bar into something runnable instead of a sequence of curl
commands reconstructed each time:
* exactly three tools, and federation_identity returns the pinned pubkey
* protected-resource metadata names the AS and both scopes
* the AS advertises both scopes (fails until console#345 deploys)
* /oauth/authorize accepts client_id=chatgpt-federation — distinguishes
"client row landed" from "not deployed yet" WITHOUT a sign-in, by telling a
real OAuth error page apart from outright rejection
* an unverifiable bearer is refused with a discovery pointer
* publish, then read the event back over a SEPARATE authenticated connection
and verify it from scratch — hash binds to content, signature verifies,
pubkey is the connector
* the marker appears exactly once (no duplicates on rerun)
The interactive authorization-code flow and the CGF_OAUTH_REQUIRED flip are
reported as PENDING rather than skipped, so an incomplete run cannot read as a
complete one.
Two things this got wrong first, both now fixed and worth keeping in mind:
* the read-back originally used a freshly generated key. relay.ruv.io is
membership-gated, so that key fails NIP-42 and the read returns nothing —
indistinguishable from "the event is not there". It now uses an admitted
verifier key (CGF_VERIFY_KEY), which is what "independent" has to mean on a
gated relay: a different admitted member, not an unknown one.
* a missing verifier key used to fail opaquely; it now names the path and what
to set.
Current state against production: 13 passed, 2 failed, 2 pending. Both failures
are the merge-blocked items (AS scopes, client row), which is the calibration
this script needs to be trusted after the merge.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
d9934d55bf |
test(federation): assert the stated acceptance bar for the three token classes
One test, three tokens signed by the same key with valid signatures, separated
only by what they are addressed to:
* a browser-session-shaped token (no iss, no aud — the shape auth_web.rs mints)
gets 401, and the challenge names the missing claims;
* a well-formed OAuth token for another Cognitum client (aud=music-cognitum-one)
gets 401;
* ours (correct issuer, aud=chatgpt-federation) succeeds, carrying exactly
federation:read and federation:publish and nothing else.
Also states the deviation plainly in the README rather than leaving it implied:
this is client-audience binding, not RFC 8707 resource binding; it is accepted
for a single-tenant connector with its own OAuth client, is not a pattern to
copy into a multi-resource service, and stops being safe the moment these scopes
are granted to a second client.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
6f68246729 |
docs(federation): record the client-id audience binding and the two traps around it
Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
3bd12277c7 |
fix(federation): bind the access token to the client_id, not the resource URL
The previous commit's retraction was itself based on stale information, and reading the authorization server settled it. auth.cognitum.one's `issue_oauth_access_token` (console services/identity/src/ jwt.rs) sets BOTH `iss` and `aud` — with `aud` = the requesting client_id. The "neither iss nor aud" measurement in minimax-music's gateway is about a different code path: `auth_web.rs` mints browser-session tokens through the unbound `issue_access_token`, and that gateway verifies session tokens. The OAuth flow has been binding all along. So the audience to expect is this connector's client_id, not its resource URL. That is client-audience binding rather than RFC 8707 resource binding (still open per console ADR-038), and it closes the same confused-deputy hole here because this connector is the only resource its client_id is registered for. That one-client-one-resource assumption is load-bearing, which is why the upstream migration says federation:* must never be granted to another client. Also refuses to start when CGF_OAUTH_REQUIRED=true without CGF_OAUTH_CLIENT_ID. Enforcing OAuth with no audience to bind to would accept any token this issuer ever minted for any Cognitum app — strictly worse than the transitional caller token it replaces, and far too quiet a way to get there. Upstream client registration: cognitum-one/console#345. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
f29945e580 |
fix(federation): name the reason when an unbound access token is refused
auth.cognitum.one is measured to issue access tokens carrying neither an `iss` nor an `aud` claim (minimax-music services/gateway/src/jwks.rs, citing freetokens ADR-0017 — and that file carries an explicit warning against adding those checks without confirming the tokens have started sending them). This resource server checks both, so every real Cognitum token would be refused — correctly, because an aud-less token is not bound to this resource and any Cognitum-integrated app holding a user's token could present it here and publish as the federation identity. That is a confused-deputy hole, and it is worse here than elsewhere because the capability being borrowed is an identity other participants trust. The refusal stays. What changes is that it now says why: on failure the token is re-verified for SIGNATURE ONLY, purely to diagnose, and a validly-signed token missing iss/aud is reported as "signature is valid but carries no iss or aud claim, so it is not bound to this resource". Every path still returns ok:false — nothing is granted by the diagnostic. Without this the symptom is a bare "invalid_token" that looks like a bad key. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
51f3a0a22e |
feat(federation): OAuth 2.1 resource server, and retract the Cloud Run header claim
Two things, and the second one corrects me. **OAuth.** Cognitum already runs an OAuth 2.1 authorization server at https://auth.cognitum.one — authorization_code + refresh_token, PKCE S256, JWKS at the usual path. There was no reason to build one. This adds the resource-server half against it: - RFC 9728 protected-resource metadata, served at both /.well-known paths that clients probe, naming the issuer and both scopes. - Bearer validation against the issuer's JWKS, with the audience checked rather than merely parsed — a token minted for another Cognitum resource must not be able to act on the federation identity just because the same issuer signed it. - federation:read gates the two reads, federation:publish gates the write. - 401 + WWW-Authenticate: Bearer resource_metadata="…" on an absent or unverifiable token, which is what starts discovery. CGF_OAUTH_REQUIRED is the switch. Unset, reads stay open and x-caller-token still authorises publish so the connector keeps working while the authorization server side is registered; set, both transitional doors close. It is the last step. Note that server is public-client only (token_endpoint_auth_methods_supported: ["none"]), so there is no client secret anywhere in this design. **Retraction.** Earlier commits and the README claimed Cloud Run consumes the Authorization header and answers 401 before the container sees it. That is wrong. Retested on a live allow-unauthenticated service across every header shape — absent, opaque garbage, JWT-shaped, non-Bearer scheme, and the real application token — on both GET / and POST /mcp. All 200, header delivered. The single 401 that produced the claim never reproduced and its cause was never established. The claim would have forced a pointless authentication facade and argued against putting the OAuth bearer where the specs require it. x-caller-token survives as the transitional door, but on its own merits now, not on that false premise. Also fixes the service-info block, whose OAuth advertisement silently missed its patch anchor and shipped without it. There is now a test for it. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
8666c5d74a |
fix(federation): pin the signing-key version and refuse an unexpected identity
Mounting `chatgpt-federation-nostr-sk:latest` makes `gcloud secrets versions add`
an identity change on the next cold start: the connector comes back as a pubkey the
relay has not admitted, every publish fails with `restricted:`, and readers tracking
the old identity see it go quiet rather than change names. Nothing in that sequence
produces a signal until publishing is already broken.
Two changes:
- The deploy and rotation docs pin an explicit secret version. Rotation becomes
add-version, admit the new pubkey, deploy a pinned revision with --no-traffic,
verify a probe independently, then route traffic, then disable the old version.
Admitting after traffic moves leaves a window where every publish fails;
disabling before the probe passes strands the connector with no way back.
- CGF_EXPECTED_PUBKEY, checked at startup, is the backstop for the deploy that
forgets to pin. The container refuses to boot under an identity other than the
one it was deployed for, so the rollover fails the deploy instead of happening
silently.
Verified in production against the real image: a revision deployed with a
deliberately wrong CGF_EXPECTED_PUBKEY failed to start, logged the refusal, never
received the traffic tag, and left the serving revision untouched.
An earlier attempt at that control passed vacuously — it reused the pre-existing
image, which had no pin code in it, and a --no-traffic revision reports Ready once
the image imports without ever starting the container. Both had to be corrected
before the control meant anything.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
506b3b90eb |
feat(federation): give ChatGPT Federation its own key and its own relay connection
buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity that
authenticated the connection, so "sign it here, let the gateway relay it" is not a
policy we chose against — it is impossible. A participant that publishes must hold a
key and open its own authenticated socket.
This adds the smallest service that does that for the ChatGPT Federation connector:
three tools, no resources, public channels only.
Key custody:
- Secret Manager secret mounted read-only at /secrets/nostr/signing-key
- a dedicated runtime service account is the only principal granted access
- no env-var key value, no generate-on-missing fallback, no accessor that
returns the bytes; a test asserts each of those structurally
- errors and logs are scrubbed of anything key-shaped
It lives in ruv-dev rather than cognitum-20260110 because cognitum grants
secretmanager.secretAccessor to the default compute service account project-wide,
and the x.ruv.io gateway runs as that account — so no per-secret binding there can
keep the gateway out. ruv-dev grants that account only roles/editor, which does not
include versions.access.
The caller token travels as x-caller-token, not Authorization: Cloud Run consumes
Authorization for its own IAM check and answers 401 before the container sees it.
Tests run a local NIP-42 relay that enforces the same identity binding as
buzz-relay, so publish is exercised end to end, including the case the whole design
turns on — an event signed by a key other than the authenticated one is refused.
Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
|
||
|
|
df87b0db33 |
chore(release): 3.41.2 — memory index protection, daemon consent, sibling disclosure (#3283)
#3224 — a curate may now grow or reorder MEMORY.md and never shrink it. The bridge rebuilt the index from its own topic files and overwrote whatever was there; the #1556 guard only skipped the write when nothing matched, but the bridge writes the first topic file itself, so after the first insight a hand-maintained index was replaced with a stub. A smaller generated view now goes beside the file instead, and the bridge says it declined. #3278 — autoStart:false is honoured. The disable check read claude-flow.config.json daemon.autostart while init generates .claude/settings.json claudeFlow.daemon.autoStart: a different file and a different capital S, so the setting a fresh project ships was never consulted, under a comment saying it exists to prevent unintended token consumption. #3228 (half) — retrieve, search and an empty list now disclose the sibling AgentDB store. 3.41.1 shipped that disclosure in `memory list` alone, so the other three kept returning a confident negative while the rows sat in the file they do not read. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67v3.41.2 |
||
|
|
8485302f10 |
feat(x-gateway): 0.6.1 — bound Seraphina by budget instead of an admin token (#3275)
* feat(x-gateway): 0.6.1 — Seraphina bounded by budget, not by an admin token Squashes what is actually running on x.ruv.io so git and production agree. Seraphina was gated alongside the write tools, but it is not like them: it reads the roster, the claims board and recent messages, asks a model, and returns advice. It writes nothing and carries no authority. The gate answered the wrong question — the exposure is model spend, and spend is bounded with a budget, not a password. The practical cost was worse than a wrong abstraction. A browser cannot hold a bearer secret, so a published UI was either locked out or tempted to ship RUFLO_ADMIN_TOKEN to the client — the same token that mints invites and publishes as the gateway identity. The safe-looking option was the catastrophic one. Seraphina now answers with no token, bounded by a shared daily cap and a per-client hourly cap, and anonymous callers cannot select the high or ultra tiers. An admin token lifts both. Every write tool keeps its gate; verified live that claims_issue, federation_invite_mint, federation_publish, federation_admit and channel_publish all still refuse an anonymous caller. Not included, deliberately: a tool to relay member-signed events through the gateway. It was built, tested against the live relay, and removed. buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 authenticated connection, so a gateway cannot publish on another identity's behalf — and it does not need to. Members already publish as themselves through the wss://x.ruv.io proxy, verified end to end. Shipping a tool that always fails would be worse than none. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 * feat(x-gateway): serve onboarding guidance, and deliberately not key generation The registry said "generate a Nostr keypair" and stopped, and it never named the two identities in play. That gap was not theoretical: a capable agent inspected the surface, found only gateway-signed publishing, and concluded ordinary members could not publish from a dashboard at all. They can. It just was not written anywhere they could reach. federation_onboarding and ruv://federation/onboarding now answer it, open, no token. The guide leads with the distinction that causes the confusion — your key signs for you, the gateway's key signs for the service, and the gated tools are gated precisely because they speak as the service. It carries the five steps, the four things never to do, and the three traps this service has actually taught us: sign the NIP-42 relay tag with the canonical URL even when proxied, the relay binds publishing to the authenticated connection, and the channel tag is `c` not `h`. It does NOT generate keys, and that is the point rather than an omission. A service that mints your keypair and returns the secret has seen your secret, and becomes custodian of every identity it "helped" — the same custody mistake as putting an admin token in a browser, inverted. The guide ships the code so the caller runs it locally and the key never crosses the wire. One test note worth keeping: the first version of the safety assertion regex- matched for "send your secret" and failed on the guide's own "Never send your secret key" line. It is now structural — no field may be named for secret material — because a string search cannot tell an instruction from its negation. Verified live on 0.7.0 (ruflo-x-gateway-00015-qj7) with an anonymous call. 20/20. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 * fix(ci): register the gateway's credential and budget knobs, and skip test/ like tests/ The ADR-125 precedence audit failed the gateway PR, and it was right to look — but for two reasons that are both about the audit, not the code. RUFLO_ADMIN_TOKEN was not registered as an escape hatch even though its sibling RUFLO_X_ADMIN_TOKEN is, with the same reasoning: a secret must never be a CLI flag, where it lands in shell history and process listings. The gateway is a long-running service with no typed command surface at all, so there is no invocation to attach a flag to. Same for the two Seraphina budget knobs added in #3275. The other half was a naming gap. SKIP_DIRS already excludes `tests` and `__tests__` because the audit is about production precedence, not test setup — but the flagged lines were in `plugins/ruflo-x-gateway/test/`, singular, which was not in the set. Two `process.env.X = 'test-admin-token'` assignments inside a test fixture were reported as undeclared production reads. Verified: the audit now exits clean on this branch. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
13880e3aa4 |
fix(memory,daemon): stop destroying a user index, honour autoStart:false, and finish the sibling disclosure (#3282)
Three from the current issue backlog, all confirmed in source before touching anything, and all with a test that fails without the fix. #3224 — curateIndex() rebuilt MEMORY.md from the bridge's own topic files and overwrote whatever was there. The #1556 guard only skips the write when NOTHING matched, but the bridge writes the first topic file itself, so after the first insight the guard stops applying and the rebuild replaces a hand-maintained index with a stub: 75 curated lines and 49 links down to 6 in the reported case. The bridge only knows its own topic files, so everything else looks like nothing to it. A curate may now grow or reorder the index and never shrink it; when the generated view is smaller it is written beside the file as MEMORY.generated.md and the bridge emits index:preserved rather than going quiet. #3278 — the autostart disable check read claude-flow.config.json with the key daemon.autostart, while init generates .claude/settings.json with claudeFlow.daemon.autoStart. A different file AND a different capital S, so the one setting a fresh project actually ships was never consulted. It is written false under the comment "Opt-in only — prevents unintended token consumption (#1427, #1330)", which the runtime then ignored. Both files and both spellings are now read: someone who wrote autostart false anywhere sensible meant it. #3228 (half) — 3.41.1 disclosed the sibling AgentDB store from `memory list` only. retrieve, search and an empty list kept returning a clean negative while the rows sat in the file this interface does not read; the reported Windows install has 31,673 rows in that other store answering found:false. A confident negative is worse than an error, because nothing prompts anyone to look further. That was a gap in my own fix, not a new defect. Verified the tests are worth having by reverting each fix and re-running: #3224 fails 1/2, #3278 fails 2/4, and all six pass again restored. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
a64f8b1ad8 |
feat(x-gateway): declare default channels so a quiet one is still discoverable (#3274)
channel_list reports what has been published to recently. That is right for finding activity and wrong for onboarding: a channel nobody posted in today does not exist as far as a newcomer can tell, so everyone starts in the flat firehose and the channels stay empty. The emptiness was self-sustaining. Four declared channels, always listed with their purpose even at zero messages: announce for releases and status, help for anyone stuck, claims so cross-host ownership has one home, showcase for what people built. Kept deliberately small. A directory of plausible-sounding empty rooms costs a newcomer more attention than no directory, because it makes them guess which ones are alive; the listing now sorts active channels first and marks the declared ones. They are also published in ruv://federation/registry, so the set is discoverable without calling a tool, and named in channel_list's own description. Second fix in the same area: a malformed `c` tag value was being reported as a channel. My own tag probe left `ruflo-probe-c` sitting in the directory looking like a real room. Listings now drop anything that is not a well-formed pub:/prv: id. Verified live on 0.5.0 (revision ruflo-x-gateway-00010-nh4): all four appear, seeded so each opens with something to read, probe traffic gone, 16/16 tests. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
cda8b04f6a |
chore(release): 3.41.1 — memory path identity and Seraphina truncation (#3273)
Two fixes, both found by someone hitting them rather than by us. #3196: an explicit database path now selects that database. The bridge cached one registry globally, so the first caller decided the file for the whole process and every later caller's --path or CLAUDE_FLOW_DB_PATH was accepted and then silently ignored. A CLI write and an MCP write landed in two files, both reporting success, and neither interface could see the other's rows. The 'memory list' command also stopped printing a confident total for one store while rows sat unread in its sibling — it now names the store it did not read. Reported downstream by pacphi/agentic-kit#213. #3267: Seraphina no longer passes a truncated reasoning dump off as coordination guidance. The routed model spent its whole token budget reasoning and never emitted its JSON, and the empty result read as "the swarm needs nothing" — the one conclusion a coordinator must never reach by accident. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67v3.41.1 |
||
|
|
2b40aee077 |
fix(seraphina): stop reporting a truncated reasoning dump as coordination guidance (#3267)
Seraphina returned HTTP 200 and a well-shaped object with zero proposals, zero risks, and a `guidance` string that was the model thinking out loud mid-sentence. Measured on the live gateway: output_tokens 2000, reasoning_tokens 2000, stop_reason max_tokens. The tier had resolved to a reasoning model whose reasoning tokens are billed against max_tokens, so the entire budget went to private reasoning and the JSON answer was never emitted. The dangerous part was not the truncation, it was that it looked like success. An empty proposals array reads as "the swarm needs nothing", which is the one conclusion a coordinator must never reach by accident. Two changes. The budget goes to 8000 so there is room to answer after reasoning. And extractJson now reports whether it actually parsed, so askSeraphina can return an explicit `degraded` result naming the cause and a retry hint instead of passing raw reasoning off as guidance. Verified live before and after: before, stop_reason max_tokens with 0 proposals and reasoning text as guidance; after, stop_reason end_turn on anthropic/claude-sonnet-5 with 4 proposals, 4 risks and real guidance. Gateway suite 14/14, including two new guards — one that a truncated reply is marked degraded with empty guidance, one that a well-formed reply is untouched. Deployed as ruflo-x-gateway-00009-qxb. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
e6f116b5c0 |
fix(memory): honour an explicit database path, and stop counting one store as if it were all of them (#3196) (#3271)
* fix(memory): honour an explicit database path, and stop counting one store as if it were all of them (#3196) Two writes, both reporting success, landing in two files neither interface could see whole. Reproduced on 3.41.0 in a clean directory: .swarm/memory.db CLI-written key 1, MCP-written key 0 .swarm/agentdb-memory.db CLI-written key 0, MCP-written key 1 The cause is a cache, not a path calculation. getRegistry() kept one global instance, so the first caller to touch the bridge decided the file for the whole process; every later caller's dbPath was accepted and then silently ignored, because the function returned the existing instance without ever comparing paths. `--path` and CLAUDE_FLOW_DB_PATH have always advertised that they select a database. Now they do: the cache is keyed by resolved path, so two paths in one process are two registries, and a relative path and its absolute form are one. The second half is worse than the split and is fixed separately. `memory list` printed "Showing 1 of 1 entries" — not a warning, not a partial result, a confident total describing one file as though it described the memory. A downstream integrator measured 51 rows in one store and 16,671 in its sibling, with 50 keys existing only in the smaller one. Silence would have been recoverable; a wrong total is not, because nothing prompts anyone to look further. The CLI now names the store it did not read, and how to read it. Deliberately NOT done: merging the two files. #2786 gave AgentDB its own plaintext file so native better-sqlite3 does not open an encrypted memory.db, where it fails with "file is not a database" and silently disables the learning system. Unifying the path would re-break encryption at rest. Both corpora stay, and both stay readable. The sibling count is read-only, opens nothing it cannot open read-only, and returns null when the file is missing or unreadable — an unreadable store is not evidence of an empty one, so the CLI stays silent rather than claim zero. Verified against the original reproduction: the MCP-written rows are now disclosed by name and count after the total. Three regression tests cover sibling identity, path resolution, and silence on an unreadable store. Reported-by: pacphi (pacphi/agentic-kit#213) Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 * fix(memory): make the bridge test seam override every path, not two guessed keys Keying the registry cache by path broke #2968's fixture. That test installs a fake registry through __setMemoryBridgeRegistryForTests and then calls bridgeStoreEntry with its own temp dbPath, so an override installed under the two keys the seam could guess was never consulted: the call resolved a third path, missed the cache, and tried a real init instead of the fake. A test seam means 'use this registry', not 'use it if the caller passes the path I assumed'. It is now a separate override that getRegistry() returns first, kept deliberately outside the path cache so the two concerns cannot collide again. Verified: #2968 and the #3196 identity tests pass together, and the bridge dbPath, durability, purge and row-visibility suites are unchanged. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |
||
|
|
cb10bf617e |
chore(release): 3.41.0 — open swarm federation CLI/MCP, channels, Seraphina
@claude-flow/cli, claude-flow, ruflo -> 3.41.0 (lockstep). Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_015y2UjPSd3VX1CYnPstZJ5Dv3.41.0 |
||
|
|
9576a1032b |
docs(federation): teach the skills and gateway docs about channels (#3263)
ADR-386 shipped the capability but the guidance still describes the old world: the open-federation skill has no channels at all and never names the canonical relay, and the gateway README and plugin manifest list a tool surface that is now three tools short. open-federation gains a Channels section with the two visibilities, the CLI verbs, and the three things worth saying before anyone trusts a private channel: metadata is not hidden, there is no revocation, and losing the key file loses the channel. It also records the tag trap — buzz-relay enforces NIP-29 group membership on `h`, so an h-tagged event publishes and then cannot be read back, which is why ruflo channels use `c`. claims gains a short section on scoping a claim stream to a channel, with the caveat that matters: a claim nobody outside the channel can read cannot arbitrate against one made outside it, so swarm-wide ownership stays on the open stream. Verified: marketplace validation passes over all 39 plugins, the manifest audit exits 0, and both skills still parse their frontmatter. Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 |