From c370956ec698e020abd9b662a67b81b3a4eb8f0b Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 20:27:15 -0400 Subject: [PATCH 1/3] Let people subscribe to a mediator address. Human clusters and statement triples opt in without a service URL. LLM ticks can publish the same cluster documents when parent causes are configured (ADR 0012). --- causestarter/TODO.md | 2 + causestarter/src/App.tsx | 2 + .../src/components/CauseMediatorCard.test.tsx | 7 + .../src/components/CauseMediatorCard.tsx | 4 +- .../components/ClusterMediatorOptIn.test.tsx | 55 ++++ .../src/components/ClusterMediatorOptIn.tsx | 85 ++++++ causestarter/src/lib/bridgeNudges.ts | 27 +- causestarter/src/lib/bridgeTriple.test.ts | 63 ++++ causestarter/src/lib/bridgeTriple.ts | 113 +++++++ causestarter/src/pages/BridgeClusterPage.tsx | 5 + .../src/pages/BridgeTriplePage.test.tsx | 38 +++ causestarter/src/pages/BridgeTriplePage.tsx | 284 ++++++++++++++++++ causestarter/src/pages/CauseMediatorPage.tsx | 6 +- docs/founder/bridge-cluster-wording-help.md | 2 +- docs/founder/mediator-for-your-cause.md | 4 +- inbox.md | 2 - .../bridge-creator/src/clusterFromTick.ts | 193 ++++++++++++ .../bridge-creator/src/clusterPublisher.ts | 46 +++ services/bridge-creator/src/config.ts | 7 + services/bridge-creator/src/index.ts | 17 ++ services/bridge-creator/src/mediatorConfig.ts | 28 ++ services/bridge-creator/src/runner.ts | 26 ++ .../test/clusterFromTick.test.ts | 55 ++++ .../test/mediatorConfig.test.ts | 16 + services/bridge-creator/test/runner.test.ts | 22 ++ .../decisions/0012-mediator-is-an-address.md | 37 +++ specs/decisions/README.md | 1 + specs/glossary.md | 1 + specs/product/README.md | 2 +- specs/product/bridge-causes.md | 8 +- specs/product/bridge-cluster-as-nudger.md | 90 ++++-- specs/product/bridge-creator.md | 2 +- .../components/StatementSuggestions.test.tsx | 18 ++ ui/src/shared/index.ts | 2 +- ui/src/shared/nudges/mediatorNudger.test.ts | 48 ++- ui/src/shared/nudges/mediatorNudger.ts | 34 ++- 36 files changed, 1288 insertions(+), 64 deletions(-) create mode 100644 causestarter/src/components/ClusterMediatorOptIn.test.tsx create mode 100644 causestarter/src/components/ClusterMediatorOptIn.tsx create mode 100644 causestarter/src/lib/bridgeTriple.test.ts create mode 100644 causestarter/src/lib/bridgeTriple.ts create mode 100644 causestarter/src/pages/BridgeTriplePage.test.tsx create mode 100644 causestarter/src/pages/BridgeTriplePage.tsx create mode 100644 services/bridge-creator/src/clusterFromTick.ts create mode 100644 services/bridge-creator/src/clusterPublisher.ts create mode 100644 services/bridge-creator/test/clusterFromTick.test.ts create mode 100644 specs/decisions/0012-mediator-is-an-address.md diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 29df9024..1fae289e 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -7,6 +7,8 @@ open **if they stay listed here**. ## Product / UX +- [x] **Cluster-page mediator opt-in** and **statement-level triples** (`/bridge/triple`) — [ADR 0012](/specs/decisions/0012-mediator-is-an-address.md). + - [ ] **Content contracts on the cause board — leftover after first slice.** Product rule (settled): list the *contract* (not individual posts) on the cause project list when any post in that contract has a current positive diff --git a/causestarter/src/App.tsx b/causestarter/src/App.tsx index 29a771a1..e73394cd 100644 --- a/causestarter/src/App.tsx +++ b/causestarter/src/App.tsx @@ -4,6 +4,7 @@ import { HomePage } from './pages/HomePage' import { StartCauseRedirect } from './pages/StartCauseRedirect' import { StartBridgeRedirect } from './pages/StartBridgeRedirect' import { BridgeClusterPage } from './pages/BridgeClusterPage' +import { BridgeTriplePage } from './pages/BridgeTriplePage' import { CausesPage } from './pages/CausesPage' import { CauseDetailPage } from './pages/CauseDetailPage' import { CauseMediatorPage } from './pages/CauseMediatorPage' @@ -54,6 +55,7 @@ export default function App() { {/* No intermediate form — creates a draft and opens the editor. */} } /> } /> + } /> } /> } /> } /> diff --git a/causestarter/src/components/CauseMediatorCard.test.tsx b/causestarter/src/components/CauseMediatorCard.test.tsx index 29d02969..62c88aab 100644 --- a/causestarter/src/components/CauseMediatorCard.test.tsx +++ b/causestarter/src/components/CauseMediatorCard.test.tsx @@ -78,6 +78,13 @@ describe('CauseMediatorCard', () => { expect(screen.getByText(/published identity is incomplete/)).toBeInTheDocument() }) + it('cannot be enabled without a service URL (featured triples need GET /anchors)', () => { + renderCard({ ...mediator, serviceUrl: '' }) + + expect(screen.getByTestId('cause-mediator-optin')).toBeDisabled() + expect(screen.getByText(/published identity is incomplete/)).toBeInTheDocument() + }) + it('still offers a deep link for clients that cannot toggle in place', () => { const path = causeMediatorOptInPath(mediator) expect(path).toContain('nudgerName=Housing+mediator') diff --git a/causestarter/src/components/CauseMediatorCard.tsx b/causestarter/src/components/CauseMediatorCard.tsx index 2377637d..e110296a 100644 --- a/causestarter/src/components/CauseMediatorCard.tsx +++ b/causestarter/src/components/CauseMediatorCard.tsx @@ -6,7 +6,7 @@ import { addTrustedNudger, isTrustedNudger, loadTrustedNudgers, - mediatorNudgerFromCause, + serviceMediatorFromCause, removeTrustedNudger, } from '@ui/shared' import type { CauseMediator } from '../lib/causeStore' @@ -38,7 +38,7 @@ export function CauseMediatorCard({ mediator, detailPath }: { /** Omitted on the mediator's own page, where the link would point at itself. */ detailPath?: string }) { - const entry = mediatorNudgerFromCause(mediator) + const entry = serviceMediatorFromCause(mediator) const [nudgers, setNudgers] = useState(loadTrustedNudgers) const optedIn = isTrustedNudger(mediator.address, nudgers) diff --git a/causestarter/src/components/ClusterMediatorOptIn.test.tsx b/causestarter/src/components/ClusterMediatorOptIn.test.tsx new file mode 100644 index 00000000..e9e24c64 --- /dev/null +++ b/causestarter/src/components/ClusterMediatorOptIn.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ClusterMediatorOptIn, clusterMediatorOptInPath } from './ClusterMediatorOptIn' + +const fields = { + mediatorAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const, + mediatorName: 'Ada Mediator', + mediatorNote: 'Hand-authored settlement.', +} + +describe('ClusterMediatorOptIn', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + cleanup() + }) + + it('opts in to the mediator address with no service URL', () => { + render() + + const button = screen.getByTestId('cluster-mediator-optin') + expect(button).toHaveTextContent('Opt in') + expect(button).not.toBeDisabled() + + fireEvent.click(button) + expect(button).toHaveTextContent('Opted in') + const stored = JSON.parse(localStorage.getItem('commonality:trustedNudgers') ?? '[]') as Array<{ + address: string + serviceUrl?: string + sourceType?: string + name: string + }> + expect(stored).toHaveLength(1) + expect(stored[0]?.address).toBe(fields.mediatorAddress) + expect(stored[0]?.name).toBe('Ada Mediator') + expect(stored[0]?.serviceUrl).toBeUndefined() + expect(stored[0]?.sourceType).toBeUndefined() + }) + + it('does not treat opening the cluster as subscribe — starts off', () => { + render() + expect(screen.getByTestId('cluster-mediator-optin')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByText(/not this page/i)).toBeInTheDocument() + }) + + it('deep-links to Settings without a service URL', () => { + const path = clusterMediatorOptInPath(fields) + const url = new URL(path, 'https://causestarter.example') + expect(url.searchParams.get('addNudger')).toBe(fields.mediatorAddress) + expect(url.searchParams.get('nudgerName')).toBe('Ada Mediator') + expect(url.searchParams.has('nudgerServiceUrl')).toBe(false) + }) +}) diff --git a/causestarter/src/components/ClusterMediatorOptIn.tsx b/causestarter/src/components/ClusterMediatorOptIn.tsx new file mode 100644 index 00000000..3a4bf9af --- /dev/null +++ b/causestarter/src/components/ClusterMediatorOptIn.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react' +import { Button, Paper, Stack, Typography } from '@mui/material' +import CheckIcon from '@mui/icons-material/Check' +import { + addTrustedNudger, + getMediatorOptInPath, + isTrustedNudger, + loadTrustedNudgers, + mediatorNudgerFromCause, + removeTrustedNudger, +} from '@ui/shared' +import type { BridgeClusterFields } from '../lib/bridgeCluster' + +const DEFAULT_DESCRIPTION = + 'Suggests modified wordings of the causes this mediator bridged. Signing stays your choice.' + +export function clusterMediatorEntry(fields: Pick) { + return mediatorNudgerFromCause({ + address: fields.mediatorAddress, + name: fields.mediatorName, + description: fields.mediatorNote.trim() || DEFAULT_DESCRIPTION, + }) +} + +export function clusterMediatorOptInPath(fields: Pick): string { + const entry = clusterMediatorEntry(fields) + if (!entry) return '/settings' + return getMediatorOptInPath(entry) +} + +/** + * Opt in to this cluster's mediator address. No service URL — republish is the tick. + */ +export function ClusterMediatorOptIn({ + fields, +}: { + fields: Pick +}) { + const entry = clusterMediatorEntry(fields) + const [nudgers, setNudgers] = useState(loadTrustedNudgers) + const optedIn = isTrustedNudger(fields.mediatorAddress, nudgers) + + const toggle = () => { + if (!entry) return + setNudgers(optedIn ? removeTrustedNudger(fields.mediatorAddress) : addTrustedNudger(entry)) + } + + return ( + + + + + Listen to this mediator + + + You are opting into {fields.mediatorName}'s address, not this page. + Later parent→modified suggestions appear if they publish again. Opening this cluster + does not subscribe you. + + + + + + ) +} diff --git a/causestarter/src/lib/bridgeNudges.ts b/causestarter/src/lib/bridgeNudges.ts index ddc84918..ed9e8f51 100644 --- a/causestarter/src/lib/bridgeNudges.ts +++ b/causestarter/src/lib/bridgeNudges.ts @@ -49,14 +49,13 @@ export function buildNudgeBatchDocument(args: { } } -export async function publishParentToModifiedNudges(args: { +export async function publishNudgeBatch(args: { writeClients: WriteClients mediatorAddress: `0x${string}` - fields: BridgeClusterFields + nudges: ParentToModifiedNudge[] }): Promise<{ batchCid: string; txHash: `0x${string}` }> { - const nudges = parentToModifiedNudges(args.fields.pairs) - if (nudges.length === 0) { - throw new Error('Add modified→parent pairs first. Nudges are parent-signer → modified plank, and we will not invent them.') + if (args.nudges.length === 0) { + throw new Error('Add parent→modified pairs first. We will not invent them.') } const publishedDataAddress = getRuntimeConfigValue('VITE_PUBLISHED_DATA_CONTRACT_ADDRESS') as `0x${string}` | undefined const nudgePublicationsAddress = getRuntimeConfigValue('VITE_NUDGE_PUBLICATIONS_CONTRACT_ADDRESS') as `0x${string}` | undefined @@ -66,7 +65,7 @@ export async function publishParentToModifiedNudges(args: { const document = buildNudgeBatchDocument({ nudger: args.mediatorAddress, - nudges, + nudges: args.nudges, }) const content = new TextEncoder().encode(JSON.stringify(document)) const batchCid = publishedDataIdToCid(computePublishedDataId(content)) @@ -88,3 +87,19 @@ export async function publishParentToModifiedNudges(args: { return { batchCid, txHash: hashes[hashes.length - 1]! } } + +export async function publishParentToModifiedNudges(args: { + writeClients: WriteClients + mediatorAddress: `0x${string}` + fields: BridgeClusterFields +}): Promise<{ batchCid: string; txHash: `0x${string}` }> { + const nudges = parentToModifiedNudges(args.fields.pairs) + if (nudges.length === 0) { + throw new Error('Add modified→parent pairs first. Nudges are parent-signer → modified plank, and we will not invent them.') + } + return publishNudgeBatch({ + writeClients: args.writeClients, + mediatorAddress: args.mediatorAddress, + nudges, + }) +} diff --git a/causestarter/src/lib/bridgeTriple.test.ts b/causestarter/src/lib/bridgeTriple.test.ts new file mode 100644 index 00000000..06742f2a --- /dev/null +++ b/causestarter/src/lib/bridgeTriple.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + applyPublishedCids, + emptyTripleDraft, + modifiedToCommonFromTriple, + parentToModifiedFromTriple, + textsToPublish, + validateTripleForPublish, +} from './bridgeTriple' + +describe('bridgeTriple', () => { + it('refuses to publish without mediator name, both modifieds, parents, and common ground', () => { + const draft = emptyTripleDraft() + expect(validateTripleForPublish(draft)).toMatch(/mediator/i) + draft.mediatorName = 'Ada' + expect(validateTripleForPublish(draft)).toMatch(/modified/i) + draft.sideA.modifiedText = 'Modified A' + draft.sideB.modifiedText = 'Modified B' + expect(validateTripleForPublish(draft)).toMatch(/parent/i) + draft.sideA.parentText = 'Parent A' + draft.sideB.parentCid = 'bafyparentb' + expect(validateTripleForPublish(draft)).toMatch(/shared ground/i) + draft.commonGroundText = 'Common' + expect(validateTripleForPublish(draft)).toBeNull() + }) + + it('publishes missing texts and does not republish CIDs', () => { + const draft = emptyTripleDraft() + draft.sideA.parentCid = 'bafyparenta' + draft.sideA.modifiedText = 'Modified A' + draft.sideB.parentText = 'Parent B' + draft.sideB.modifiedCid = 'bafymodb' + draft.commonGroundText = 'Common' + const texts = textsToPublish(draft) + expect(texts.map((item) => item.key)).toEqual(['sideA.modified', 'sideB.parent', 'commonGround']) + }) + + it('nudges parent → modified, never parent → common ground', () => { + const draft = emptyTripleDraft() + draft.sideA.parentCid = 'bafyparenta' + draft.sideA.modifiedCid = 'bafymoda' + draft.sideB.parentCid = 'bafyparentb' + draft.sideB.modifiedCid = 'bafymodb' + draft.commonGroundCid = 'bafycommon' + expect(parentToModifiedFromTriple(draft)).toEqual([ + { targetStatementCid: 'bafyparenta', suggestedStatementCid: 'bafymoda' }, + { targetStatementCid: 'bafyparentb', suggestedStatementCid: 'bafymodb' }, + ]) + expect(modifiedToCommonFromTriple(draft)).toEqual([ + { fromCid: 'bafymoda', toCid: 'bafycommon' }, + { fromCid: 'bafymodb', toCid: 'bafycommon' }, + ]) + }) + + it('fills CIDs from a publish pass', () => { + const next = applyPublishedCids(emptyTripleDraft(), { + 'sideA.modified': 'bafymoda', + commonGround: 'bafycommon', + }) + expect(next.sideA.modifiedCid).toBe('bafymoda') + expect(next.commonGroundCid).toBe('bafycommon') + }) +}) diff --git a/causestarter/src/lib/bridgeTriple.ts b/causestarter/src/lib/bridgeTriple.ts new file mode 100644 index 00000000..096ef832 --- /dev/null +++ b/causestarter/src/lib/bridgeTriple.ts @@ -0,0 +1,113 @@ +/** + * Statement-level bridge triples for a human mediator with no parent causes + * and no HTTP service. Same editorial job as a cluster; same listener address. + * See specs/product/bridge-cluster-as-nudger.md Slice 3. + */ + +export interface TripleSide { + label: string + /** Existing statement CID people already signed, if any. */ + parentCid: string + /** New parent wording when there is no CID yet. */ + parentText: string + modifiedText: string + modifiedCid: string +} + +export interface TripleDraft { + mediatorName: string + mediatorNote: string + sideA: TripleSide + sideB: TripleSide + commonGroundText: string + commonGroundCid: string +} + +export function emptyTripleSide(label: string): TripleSide { + return { label, parentCid: '', parentText: '', modifiedText: '', modifiedCid: '' } +} + +export function emptyTripleDraft(): TripleDraft { + return { + mediatorName: '', + mediatorNote: '', + sideA: emptyTripleSide('One side'), + sideB: emptyTripleSide('The other side'), + commonGroundText: '', + commonGroundCid: '', + } +} + +export function parentCidOrEmpty(side: TripleSide): string { + return side.parentCid.trim() +} + +export function textsToPublish(draft: TripleDraft): { key: string; text: string }[] { + const items: { key: string; text: string }[] = [] + for (const [key, side] of [['sideA', draft.sideA], ['sideB', draft.sideB]] as const) { + if (!side.parentCid.trim() && side.parentText.trim()) { + items.push({ key: `${key}.parent`, text: side.parentText.trim() }) + } + if (side.modifiedText.trim() && !side.modifiedCid.trim()) { + items.push({ key: `${key}.modified`, text: side.modifiedText.trim() }) + } + } + if (draft.commonGroundText.trim() && !draft.commonGroundCid.trim()) { + items.push({ key: 'commonGround', text: draft.commonGroundText.trim() }) + } + return items +} + +export function applyPublishedCids( + draft: TripleDraft, + published: Record, +): TripleDraft { + const next: TripleDraft = { + ...draft, + sideA: { ...draft.sideA }, + sideB: { ...draft.sideB }, + } + if (published['sideA.parent']) next.sideA.parentCid = published['sideA.parent'] + if (published['sideA.modified']) next.sideA.modifiedCid = published['sideA.modified'] + if (published['sideB.parent']) next.sideB.parentCid = published['sideB.parent'] + if (published['sideB.modified']) next.sideB.modifiedCid = published['sideB.modified'] + if (published.commonGround) next.commonGroundCid = published.commonGround + return next +} + +export function validateTripleForPublish(draft: TripleDraft): string | null { + if (!draft.mediatorName.trim()) return 'Name the mediator. Authorship has to be loud.' + for (const side of [draft.sideA, draft.sideB]) { + if (!side.modifiedText.trim() && !side.modifiedCid.trim()) { + return `Write a modified wording for “${side.label || 'this side'}”.` + } + if (!side.parentCid.trim() && !side.parentText.trim()) { + return `Give “${side.label || 'this side'}” an existing parent CID or write the parent wording.` + } + } + if (!draft.commonGroundText.trim() && !draft.commonGroundCid.trim()) { + return 'Write the shared ground both modified wordings should imply.' + } + return null +} + +export function parentToModifiedFromTriple(draft: TripleDraft): { targetStatementCid: string; suggestedStatementCid: string }[] { + const pairs: { targetStatementCid: string; suggestedStatementCid: string }[] = [] + for (const side of [draft.sideA, draft.sideB]) { + const parent = side.parentCid.trim() + const modified = side.modifiedCid.trim() + if (parent && modified && parent !== modified) { + pairs.push({ targetStatementCid: parent, suggestedStatementCid: modified }) + } + } + return pairs +} + +export function modifiedToCommonFromTriple(draft: TripleDraft): { fromCid: string; toCid: string }[] { + const common = draft.commonGroundCid.trim() + if (!common) return [] + return [draft.sideA, draft.sideB] + .map((side) => side.modifiedCid.trim()) + .filter((cid) => cid && cid !== common) + .map((fromCid) => ({ fromCid, toCid: common })) +} diff --git a/causestarter/src/pages/BridgeClusterPage.tsx b/causestarter/src/pages/BridgeClusterPage.tsx index 5a608553..90c343b2 100644 --- a/causestarter/src/pages/BridgeClusterPage.tsx +++ b/causestarter/src/pages/BridgeClusterPage.tsx @@ -58,6 +58,7 @@ import { useMachinery } from '../lib/useMachinery' import { useWriteClients } from '../lib/useWriteClients' import { ConnectWalletHint } from '../components/ConnectWalletHint' import { BridgeClusterAssist } from '../components/BridgeClusterAssist' +import { ClusterMediatorOptIn } from '../components/ClusterMediatorOptIn' function slugOrEmpty(raw: string): string { return raw.trim() ? normalizeSlug(raw) : '' @@ -625,6 +626,8 @@ export function BridgeClusterPage() { )} + + Nudge path: parent → modified @@ -765,6 +768,8 @@ export function BridgeClusterPage() { yet. Draft a thinner modified wording when there is a real parent; a stand-in may skip that hop. Draft the shared bridge and record plank-to-plank pairs. You remain the publisher. This does not replace the in-cause mediator. + {' '}If the sides are not causes,{' '} + write a statement-level triple instead. diff --git a/causestarter/src/pages/BridgeTriplePage.test.tsx b/causestarter/src/pages/BridgeTriplePage.test.tsx new file mode 100644 index 00000000..4065db34 --- /dev/null +++ b/causestarter/src/pages/BridgeTriplePage.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BridgeTriplePage } from './BridgeTriplePage' + +vi.mock('wagmi', () => ({ + useAccount: () => ({ address: undefined, isConnected: false }), + useConnect: () => ({ connectAsync: vi.fn(), connectors: [], isPending: false }), + useDisconnect: () => ({ disconnectAsync: vi.fn() }), +})) + +vi.mock('../lib/useMachinery', () => ({ + useMachinery: () => ({}), +})) + +vi.mock('../lib/useWriteClients', () => ({ + useWriteClients: () => null, +})) + +describe('BridgeTriplePage', () => { + afterEach(() => { + cleanup() + }) + + it('is a human authoring surface: no service URL, cluster is the other form', () => { + render( + + + , + ) + expect(screen.getByTestId('bridge-triple-page')).toBeInTheDocument() + expect(screen.getByTestId('triple-publish-statements')).toBeInTheDocument() + expect(screen.getByText(/No/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: /cause cluster/i })).toHaveAttribute('href', '/bridge/new') + expect(screen.queryByTestId('cluster-mediator-optin')).not.toBeInTheDocument() + expect(screen.getByTestId('triple-publish-nudges')).toBeDisabled() + }) +}) diff --git a/causestarter/src/pages/BridgeTriplePage.tsx b/causestarter/src/pages/BridgeTriplePage.tsx new file mode 100644 index 00000000..4a057afa --- /dev/null +++ b/causestarter/src/pages/BridgeTriplePage.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react' +import { Alert, Box, Button, Paper, Stack, TextField, Typography } from '@mui/material' +import { Link as RouterLink } from 'react-router-dom' +import { useAccount } from 'wagmi' +import { ClusterMediatorOptIn } from '../components/ClusterMediatorOptIn' +import { ConnectWalletHint } from '../components/ConnectWalletHint' +import { + applyPublishedCids, + emptyTripleDraft, + modifiedToCommonFromTriple, + parentToModifiedFromTriple, + textsToPublish, + validateTripleForPublish, + type TripleDraft, + type TripleSide, +} from '../lib/bridgeTriple' +import { publishNudgeBatch } from '../lib/bridgeNudges' +import { formatPairSummary, submitPairsToAttester } from '../lib/implicationAttesterClient' +import { publishPlank } from '../lib/publishPlank' +import { useMachinery } from '../lib/useMachinery' +import { useWriteClients } from '../lib/useWriteClients' + +function SideFields({ + title, + side, + onChange, +}: { + title: string + side: TripleSide + onChange: (next: TripleSide) => void +}) { + return ( + + {title} + + onChange({ ...side, label: event.target.value })} + /> + onChange({ ...side, parentCid: event.target.value })} + /> + onChange({ ...side, parentText: event.target.value })} + disabled={Boolean(side.parentCid.trim())} + /> + onChange({ ...side, modifiedText: event.target.value })} + /> + {side.modifiedCid && ( + Published modified CID: {side.modifiedCid} + )} + + + ) +} + +export function BridgeTriplePage() { + const machinery = useMachinery() + const { address, isConnected } = useAccount() + const writeClients = useWriteClients(address) + const [draft, setDraft] = useState(emptyTripleDraft) + const [busy, setBusy] = useState(false) + const [status, setStatus] = useState(null) + + const patch = (partial: Partial) => setDraft((current) => ({ ...current, ...partial })) + + const runPublishStatements = async () => { + const problem = validateTripleForPublish(draft) + if (problem) { + setStatus(problem) + return + } + if (!writeClients) { + setStatus('Connect the mediator wallet first.') + return + } + setBusy(true) + setStatus('Publishing statements…') + try { + const published: Record = {} + for (const item of textsToPublish(draft)) { + published[item.key] = await publishPlank({ machinery, writeClients, text: item.text }) + } + const next = applyPublishedCids(draft, published) + setDraft(next) + setStatus( + Object.keys(published).length === 0 + ? 'Statements already have CIDs.' + : `Published ${Object.keys(published).length} statement(s). Nudges are parent → modified.`, + ) + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const runPublishNudges = async () => { + if (!writeClients || !address) { + setStatus('Connect the mediator wallet first.') + return + } + const pairs = parentToModifiedFromTriple(draft) + if (pairs.length === 0) { + setStatus('Publish statements first so parent and modified have CIDs.') + return + } + setBusy(true) + setStatus('Publishing parent→modified nudge batch…') + try { + const batch = await publishNudgeBatch({ + writeClients, + mediatorAddress: address, + nudges: pairs.map((pair) => ({ + ...pair, + reason: 'Mediator wording of your side. Signing it still implies the parent statement.', + confidence: 0.8, + })), + }) + setStatus(`Published parent→modified nudges (${batch.batchCid.slice(0, 12)}…).`) + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const runSubmitPairs = async () => { + if (!writeClients) { + setStatus('Connect the mediator wallet first.') + return + } + const pairs = modifiedToCommonFromTriple(draft) + if (pairs.length === 0) { + setStatus('Publish modified and common-ground statements first.') + return + } + setBusy(true) + setStatus('Paying the implication attester for modified→common-ground pairs…') + try { + const submitted = await submitPairsToAttester({ writeClients, pairs }) + setStatus(formatPairSummary(submitted.results)) + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + } + + const canOptIn = Boolean(address && draft.mediatorName.trim()) + + return ( + + + + Statement-level triple + + + Write a triple yourself + + + When the sides are not published causes, write two modified wordings and shared + ground as statements. No bridge-creator process. People subscribe to + your address. Nudges go parent → modified, never parent → compromise. + {' '} + Write a cause cluster instead + {' '}if the parents are causes. + + + + {!isConnected && ( + + Connect the mediator wallet. Statements and nudge batches publish under your key. + + )} + + + Mediator + + patch({ mediatorName: event.target.value })} + /> + patch({ mediatorNote: event.target.value })} + /> + + + + patch({ sideA })} /> + patch({ sideB })} /> + + + Shared ground + patch({ commonGroundText: event.target.value })} + /> + {draft.commonGroundCid && ( + + Published CID: {draft.commonGroundCid} + + )} + + + + + + + + + {status && {status}} + + {canOptIn && address && ( + + )} + + ) +} diff --git a/causestarter/src/pages/CauseMediatorPage.tsx b/causestarter/src/pages/CauseMediatorPage.tsx index e263ea87..9345e8fc 100644 --- a/causestarter/src/pages/CauseMediatorPage.tsx +++ b/causestarter/src/pages/CauseMediatorPage.tsx @@ -136,8 +136,10 @@ export function CauseMediatorPage() { Advanced. If you just want to write one bridge yourself, use{' '} - Create a bridge instead — no - service required. + Create a cluster + {' '}(parents are causes) or{' '} + write a statement-level triple + {' '}— no service required. ; + modified: Array<{ + owner: `0x${string}`; + slug: string; + parentOwner: `0x${string}`; + parentSlug: string; + }>; + bridge: { owner: `0x${string}`; slug: string }; + pairs: Array<{ fromCid: string; toCid: string; role: 'modified-to-bridge' }>; +} + +export interface TickClusterPlan { + clusterSlug: string; + rosters: ClusterRosterPlan[]; + cluster: ClusterDocumentPlan; +} + +export function slugifyCluster(raw: string): string { + const slug = raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return slug || 'bridge-cluster'; +} + +export function planClusterFromTick(args: { + mediatorName: string; + mediatorNote: string; + mediatorAddress: `0x${string}`; + clusterSlug?: string; + parentCauses: ParentCauseRef[]; + triples: TickTripleCids[]; +}): TickClusterPlan | null { + if (args.parentCauses.length === 0 || args.triples.length === 0) return null; + const clusterSlug = slugifyCluster(args.clusterSlug || args.mediatorName); + const owner = args.mediatorAddress.toLowerCase() as `0x${string}`; + const sideAParents = args.parentCauses.filter((parent) => parent.side === 'side_a'); + const sideBParents = args.parentCauses.filter((parent) => parent.side === 'side_b'); + if (sideAParents.length === 0 || sideBParents.length === 0) return null; + + const sideAPlanks = [...new Set(args.triples.map((triple) => triple.sideACid))]; + const sideBPlanks = [...new Set(args.triples.map((triple) => triple.sideBCid))]; + const bridgePlanks = [...new Set(args.triples.map((triple) => triple.commonGroundCid))]; + + const rosters: ClusterRosterPlan[] = []; + const modified: ClusterDocumentPlan['modified'] = []; + + for (const parent of sideAParents) { + const slug = slugifyCluster(`${clusterSlug}-${parent.slug}-modified`); + rosters.push({ + slug, + title: `${args.mediatorName}: ${parent.slug} (modified)`, + summary: `Mediator wording of ${parent.slug}. Not an official revision.`, + plankCids: sideAPlanks, + parentOwner: parent.owner.toLowerCase() as `0x${string}`, + parentSlug: parent.slug, + role: 'modified', + }); + modified.push({ + owner, + slug, + parentOwner: parent.owner.toLowerCase() as `0x${string}`, + parentSlug: parent.slug, + }); + } + for (const parent of sideBParents) { + const slug = slugifyCluster(`${clusterSlug}-${parent.slug}-modified`); + rosters.push({ + slug, + title: `${args.mediatorName}: ${parent.slug} (modified)`, + summary: `Mediator wording of ${parent.slug}. Not an official revision.`, + plankCids: sideBPlanks, + parentOwner: parent.owner.toLowerCase() as `0x${string}`, + parentSlug: parent.slug, + role: 'modified', + }); + modified.push({ + owner, + slug, + parentOwner: parent.owner.toLowerCase() as `0x${string}`, + parentSlug: parent.slug, + }); + } + + const bridgeSlug = slugifyCluster(`${clusterSlug}-bridge`); + rosters.push({ + slug: bridgeSlug, + title: `${args.mediatorName}: shared ground`, + summary: 'Bridge cause implied by each modified wording.', + plankCids: bridgePlanks, + role: 'bridge', + }); + + const pairs: ClusterDocumentPlan['pairs'] = args.triples.flatMap((triple) => [ + { fromCid: triple.sideACid, toCid: triple.commonGroundCid, role: 'modified-to-bridge' as const }, + { fromCid: triple.sideBCid, toCid: triple.commonGroundCid, role: 'modified-to-bridge' as const }, + ]); + + return { + clusterSlug, + rosters, + cluster: { + mediatorName: args.mediatorName, + mediatorNote: args.mediatorNote, + mediatorAddress: owner, + clusterSlug, + parents: args.parentCauses.map((parent) => ({ + owner: parent.owner.toLowerCase() as `0x${string}`, + slug: parent.slug, + })), + modified, + bridge: { owner, slug: bridgeSlug }, + pairs, + }, + }; +} + +export function rosterDocumentFromPlan(plan: ClusterRosterPlan): Record { + return { + format: 'markdown-restricted', + content: `# ${plan.title}\n\n${plan.summary}`, + assets: {}, + references: plan.plankCids.map((cid) => ({ cid, label: 'plank' })), + extras: { + kind: ROSTER_KIND, + version: ROSTER_SCHEMA_VERSION, + title: plan.title, + summary: plan.summary, + plankCids: plan.plankCids, + mediatorBlurb: '', + }, + }; +} + +export function clusterDocumentFromPlan(plan: ClusterDocumentPlan): Record { + return { + format: 'markdown-restricted', + content: `# Bridge cluster\n\nMediator: ${plan.mediatorName}`, + assets: {}, + references: [], + extras: { + kind: BRIDGE_CLUSTER_KIND, + version: BRIDGE_CLUSTER_SCHEMA_VERSION, + mediatorName: plan.mediatorName, + mediatorNote: plan.mediatorNote, + mediatorAddress: plan.mediatorAddress, + parents: plan.parents, + modified: plan.modified, + bridge: plan.bridge, + pairs: plan.pairs, + }, + }; +} diff --git a/services/bridge-creator/src/clusterPublisher.ts b/services/bridge-creator/src/clusterPublisher.ts new file mode 100644 index 00000000..73223d5e --- /dev/null +++ b/services/bridge-creator/src/clusterPublisher.ts @@ -0,0 +1,46 @@ +import { MutableRefUpdaterAbi, PublishedDataAbi } from '@commonality/sdk/abis'; +import { createDefaultDocumentStore, type DisplayableDocument } from '@commonality/sdk/displayable-documents'; +import type { SDKMachinery } from '@commonality/sdk/machinery'; +import { updateRef } from '@commonality/sdk/mutable-refs'; +import type { WriteClients } from '@commonality/sdk/utils'; +import type { Abi } from 'viem'; +import { + clusterDocumentFromPlan, + rosterDocumentFromPlan, + type TickClusterPlan, +} from './clusterFromTick.js'; + +export interface ClusterPublisherOptions { + clients: WriteClients; + publishedDataContractAddress: `0x${string}`; + mutableRefUpdaterContractAddress: `0x${string}`; +} + +export async function publishTickClusterDocuments( + machinery: SDKMachinery, + plan: TickClusterPlan, + options: ClusterPublisherOptions, +): Promise<{ clusterCid: string; rosterCids: string[] }> { + const store = createDefaultDocumentStore(machinery, { + clients: options.clients, + publishedDataContract: { + address: options.publishedDataContractAddress, + abi: PublishedDataAbi as Abi, + }, + }); + const refContract = { + address: options.mutableRefUpdaterContractAddress, + abi: MutableRefUpdaterAbi as Abi, + }; + + const rosterCids: string[] = []; + for (const roster of plan.rosters) { + const published = await store.publish(rosterDocumentFromPlan(roster) as unknown as DisplayableDocument); + rosterCids.push(published.cid); + await updateRef(options.clients, refContract, roster.slug, published.cid); + } + + const clusterPublished = await store.publish(clusterDocumentFromPlan(plan.cluster) as unknown as DisplayableDocument); + await updateRef(options.clients, refContract, plan.clusterSlug, clusterPublished.cid); + return { clusterCid: clusterPublished.cid, rosterCids }; +} diff --git a/services/bridge-creator/src/config.ts b/services/bridge-creator/src/config.ts index afe4efc0..ec2c6f55 100644 --- a/services/bridge-creator/src/config.ts +++ b/services/bridge-creator/src/config.ts @@ -1,6 +1,7 @@ import type { LlmNudgerConfig } from '@commonality/nudger-core'; import { parseTrustedContextSources, type TrustedContextSourceConfig } from './contextSources.js'; import { loadMediatorConfigArtifact } from './mediatorConfig.js'; +import type { ParentCauseRef } from './clusterFromTick.js'; export interface BridgeCreatorConfig extends LlmNudgerConfig { trustedContextSources: TrustedContextSourceConfig[]; @@ -18,6 +19,9 @@ export interface BridgeCreatorConfig extends LlmNudgerConfig { implicationsContractAddress?: `0x${string}`; /** Optional PublishedData contract for bridge-created conceptspace statements. */ publishedDataContractAddress?: `0x${string}`; + mutableRefUpdaterContractAddress?: `0x${string}`; + parentCauses: ParentCauseRef[]; + clusterSlug?: string; contact?: string; corsOrigins: string[]; // External bridge-proposal API (POST /propose-bridge), paid via x402. @@ -112,6 +116,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): BridgeCreatorC anchorReflectionOutcomeSummaryPath: env.BRIDGE_CREATOR_ANCHOR_REFLECTION_OUTCOME_SUMMARY_PATH || undefined, implicationsContractAddress: readOptionalAddress(env.IMPLICATIONS_CONTRACT_ADDRESS), publishedDataContractAddress: readOptionalAddress(env.PUBLISHED_DATA_CONTRACT_ADDRESS), + mutableRefUpdaterContractAddress: readOptionalAddress(env.MUTABLE_REF_UPDATER_CONTRACT_ADDRESS), + parentCauses: mediator?.parent_causes ?? [], + clusterSlug: mediator?.cluster_slug, contact: env.BRIDGE_CREATOR_CONTACT || undefined, corsOrigins: parseCorsOrigins(env.BRIDGE_CREATOR_CORS_ORIGINS), proposalStorePath: readString(env, ['BRIDGE_CREATOR_PROPOSAL_STORE_PATH'], 'services/bridge-creator/data/proposals.json'), diff --git a/services/bridge-creator/src/index.ts b/services/bridge-creator/src/index.ts index d784c9c8..9ec12923 100644 --- a/services/bridge-creator/src/index.ts +++ b/services/bridge-creator/src/index.ts @@ -25,6 +25,7 @@ import { synthesizeBridgeTriples as defaultSynthesizeBridgeTriples } from './syn import { appendAnchorReflectionProposals, reflectAnchorProposals } from './anchorReflection.js'; import { loadMediatorAnchors, loadMediatorStrategyPrompt, saveMediatorAnchors } from './mediatorConfig.js'; import { runBridgeCreatorTick } from './runner.js'; +import { publishTickClusterDocuments } from './clusterPublisher.js'; export { loadConfigFromEnv }; export type { BridgeCreatorConfig } from './config.js'; export { publishBridgeStatement } from './statementPublisher.js'; @@ -74,6 +75,8 @@ export { } from './dedup.js'; export type { BridgePublicationDedupState } from './dedup.js'; export { createNudgesForPublishedTriples, runBridgeCreatorTick } from './runner.js'; +export { planClusterFromTick } from './clusterFromTick.js'; +export { publishTickClusterDocuments } from './clusterPublisher.js'; export type { BridgeCreatorRunnerDependencies, BridgeCreatorTickResult, BridgeCreatorTickStatus } from './runner.js'; import { createNudgerSigner } from '@commonality/nudger-core'; @@ -318,6 +321,20 @@ export function run(config = loadConfig()): BridgeCreatorRunHandle { loadProposalStore: loadProposalStoreFile, markProposalsConsumed, implicationSubmitter, + publishTickCluster: + config.parentCauses.length > 0 + && config.publishedDataContractAddress + && config.mutableRefUpdaterContractAddress + ? (plan) => publishTickClusterDocuments(machinery, plan, { + clients: bridgeWriteClients, + publishedDataContractAddress: config.publishedDataContractAddress!, + mutableRefUpdaterContractAddress: config.mutableRefUpdaterContractAddress!, + }).then((published) => { + console.log( + `Bridge creator cluster published: /bridge/${plan.cluster.mediatorAddress}/${plan.clusterSlug} cid=${published.clusterCid}`, + ); + }) + : undefined, }); console.log( `Bridge creator tick: ${result.status}; synthesized=${result.synthesizedBridgeCount}; published_nudges=${result.publishedNudgeCount}`, diff --git a/services/bridge-creator/src/mediatorConfig.ts b/services/bridge-creator/src/mediatorConfig.ts index 67e0a738..fec6b7a2 100644 --- a/services/bridge-creator/src/mediatorConfig.ts +++ b/services/bridge-creator/src/mediatorConfig.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { normalizeAnchorStoreFile, type BridgeAnchorRecord } from './anchors.js'; import { parseTrustedContextSources, type TrustedContextSourceConfig } from './contextSources.js'; +import type { ParentCauseRef } from './clusterFromTick.js'; /** Provisional for one revision, pending the first live founder rehearsal. */ export const MEDIATOR_CONFIG_SCHEMA_VERSION = 'provisional-v1' as const; @@ -16,6 +17,9 @@ export interface MediatorConfigArtifact { anchors: BridgeAnchorRecord[]; context_sources: TrustedContextSourceConfig[]; signer_private_key_env: string; + /** When set, a tick may also publish a cause-cluster under this signer. */ + parent_causes: ParentCauseRef[]; + cluster_slug?: string; } export function loadMediatorConfigArtifact(path: string, env: NodeJS.ProcessEnv = process.env): MediatorConfigArtifact { @@ -41,6 +45,10 @@ export function loadMediatorConfigArtifact(path: string, env: NodeJS.ProcessEnv anchors: normalizeAnchorStoreFile({ anchors: value.anchors }).anchors, context_sources: contextSources, signer_private_key_env: requireString(value.signer_private_key_env, 'signer_private_key_env'), + parent_causes: parseParentCauses(value.parent_causes), + cluster_slug: typeof value.cluster_slug === 'string' && value.cluster_slug.trim() + ? value.cluster_slug.trim() + : undefined, }; if (!env[artifact.signer_private_key_env]) { throw new Error(`Missing mediator signer secret environment variable: ${artifact.signer_private_key_env}`); @@ -78,9 +86,29 @@ export function scaffoldMediatorConfig(foundingStatement: string, name = 'REPLAC anchors: [], context_sources: [], signer_private_key_env: 'BRIDGE_CREATOR_PRIVATE_KEY', + parent_causes: [], }; } +function parseParentCauses(value: unknown): ParentCauseRef[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error('Mediator config parent_causes must be an array'); + return value.map((entry, index) => { + if (!entry || typeof entry !== 'object') throw new Error(`parent_causes[${index}] must be an object`); + const record = entry as Record; + const owner = requireString(record.owner, `parent_causes[${index}].owner`); + if (!/^0x[0-9a-fA-F]{40}$/.test(owner)) { + throw new Error(`parent_causes[${index}].owner must be a 0x-prefixed address`); + } + const slug = requireString(record.slug, `parent_causes[${index}].slug`); + const side = requireString(record.side, `parent_causes[${index}].side`); + if (side !== 'side_a' && side !== 'side_b') { + throw new Error(`parent_causes[${index}].side must be side_a or side_b`); + } + return { owner: owner.toLowerCase() as `0x${string}`, slug, side }; + }); +} + function requireFounderPrompt(value: unknown): string { const prompt = requireString(value, 'strategy_prompt'); if (prompt.startsWith('REPLACE WITH')) throw new Error('Mediator config requires a founder-written strategy_prompt'); diff --git a/services/bridge-creator/src/runner.ts b/services/bridge-creator/src/runner.ts index 57312ac4..319fef98 100644 --- a/services/bridge-creator/src/runner.ts +++ b/services/bridge-creator/src/runner.ts @@ -16,6 +16,8 @@ import { saveBridgePublicationDedupState, summarizePublishedBridgeTriples, } from './dedup.js'; +import { planClusterFromTick, type TickClusterPlan } from './clusterFromTick.js'; +import { createNudgerSigner } from '@commonality/nudger-core'; export type BridgeCreatorTickStatus = 'warming' | 'duplicate' | 'no_bridges' | 'published'; @@ -26,6 +28,7 @@ export interface BridgeCreatorTickResult { publication?: BridgePublicationResult; implicationTxHashes: string[]; inputHash?: string; + clusterSlug?: string; } export interface BridgeCreatorRunnerDependencies { @@ -40,6 +43,7 @@ export interface BridgeCreatorRunnerDependencies { loadProposalStore: typeof loadProposalStoreFile; markProposalsConsumed: typeof markProposalsConsumed; implicationSubmitter?: BridgeImplicationSubmitter; + publishTickCluster?: (plan: TickClusterPlan) => Promise; } const defaultDependencies: BridgeCreatorRunnerDependencies = { @@ -143,6 +147,27 @@ export async function runBridgeCreatorTick( lastPublicationSummary: summarizePublishedBridgeTriples(triples), }); + let clusterSlug: string | undefined; + if (config.parentCauses.length > 0 && dependencies.publishTickCluster) { + const mediatorAddress = createNudgerSigner(config).address as `0x${string}`; + const clusterPlan = planClusterFromTick({ + mediatorName: config.name, + mediatorNote: config.description, + mediatorAddress, + clusterSlug: config.clusterSlug, + parentCauses: config.parentCauses, + triples: publishedTriples.map((published) => ({ + sideACid: published.sideACid, + sideBCid: published.sideBCid, + commonGroundCid: published.commonGroundCid, + })), + }); + if (clusterPlan) { + await dependencies.publishTickCluster(clusterPlan); + clusterSlug = clusterPlan.clusterSlug; + } + } + return { status: 'published', synthesizedBridgeCount: triples.length, @@ -150,6 +175,7 @@ export async function runBridgeCreatorTick( publication, implicationTxHashes, inputHash, + clusterSlug, }; } diff --git a/services/bridge-creator/test/clusterFromTick.test.ts b/services/bridge-creator/test/clusterFromTick.test.ts new file mode 100644 index 00000000..7e78a3b8 --- /dev/null +++ b/services/bridge-creator/test/clusterFromTick.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert'; +import { + BRIDGE_CLUSTER_KIND, + ROSTER_KIND, + clusterDocumentFromPlan, + planClusterFromTick, + rosterDocumentFromPlan, +} from '../src/clusterFromTick.js'; + +const parentA = { + owner: '0x1111111111111111111111111111111111111111' as const, + slug: 'natural-left', + side: 'side_a' as const, +}; +const parentB = { + owner: '0x2222222222222222222222222222222222222222' as const, + slug: 'natural-right', + side: 'side_b' as const, +}; + +describe('planClusterFromTick', () => { + it('returns null without parent causes (CSM stays statement-level)', () => { + assert.strictEqual(planClusterFromTick({ + mediatorName: 'Ada', + mediatorNote: '', + mediatorAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + parentCauses: [], + triples: [{ sideACid: 'a', sideBCid: 'b', commonGroundCid: 'c' }], + }), null); + }); + + it('lifts this tick into n+1 rosters plus a cluster document CauseStarter can parse', () => { + const plan = planClusterFromTick({ + mediatorName: 'Ada Mediator', + mediatorNote: 'Tick cluster', + mediatorAddress: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + clusterSlug: 'housing-bridge', + parentCauses: [parentA, parentB], + triples: [{ sideACid: 'bafymoda', sideBCid: 'bafymodb', commonGroundCid: 'bafycommon' }], + }); + assert.ok(plan); + assert.strictEqual(plan.clusterSlug, 'housing-bridge'); + assert.strictEqual(plan.rosters.length, 3); + assert.strictEqual(plan.cluster.mediatorAddress, '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + assert.deepStrictEqual(plan.cluster.pairs, [ + { fromCid: 'bafymoda', toCid: 'bafycommon', role: 'modified-to-bridge' }, + { fromCid: 'bafymodb', toCid: 'bafycommon', role: 'modified-to-bridge' }, + ]); + const clusterDoc = clusterDocumentFromPlan(plan.cluster); + assert.strictEqual((clusterDoc.extras as { kind: string }).kind, BRIDGE_CLUSTER_KIND); + const rosterDoc = rosterDocumentFromPlan(plan.rosters[0]!); + assert.strictEqual((rosterDoc.extras as { kind: string }).kind, ROSTER_KIND); + assert.deepStrictEqual((rosterDoc.extras as { plankCids: string[] }).plankCids, ['bafymoda']); + }); +}); diff --git a/services/bridge-creator/test/mediatorConfig.test.ts b/services/bridge-creator/test/mediatorConfig.test.ts index d67bd8bf..bf628022 100644 --- a/services/bridge-creator/test/mediatorConfig.test.ts +++ b/services/bridge-creator/test/mediatorConfig.test.ts @@ -18,6 +18,7 @@ describe('mediator config artifact', () => { const loaded = loadMediatorConfigArtifact(path, { HOUSING_MEDIATOR_KEY: '0xsecret' }); assert.deepStrictEqual(loaded.labels, { side_a: 'homeowners', side_b: 'renters' }); assert.strictEqual(loaded.context_sources[0]?.serviceUrl, 'https://beat.example'); + assert.deepStrictEqual(loaded.parent_causes, []); }); it('scaffolds blanks rather than shipping a strategy opinion', () => { @@ -34,4 +35,19 @@ describe('mediator config artifact', () => { writeFileSync(path, JSON.stringify(validArtifact)); assert.throws(() => loadMediatorConfigArtifact(path, {}), /signer secret environment variable/); }); + + it('loads optional parent causes for cluster publication', () => { + const path = join(mkdtempSync(join(tmpdir(), 'mediator-')), 'config.json'); + writeFileSync(path, JSON.stringify({ + ...validArtifact, + cluster_slug: 'housing-bridge', + parent_causes: [ + { owner: '0x1111111111111111111111111111111111111111', slug: 'homeowners', side: 'side_a' }, + { owner: '0x2222222222222222222222222222222222222222', slug: 'renters', side: 'side_b' }, + ], + })); + const loaded = loadMediatorConfigArtifact(path, { HOUSING_MEDIATOR_KEY: '0xsecret' }); + assert.strictEqual(loaded.cluster_slug, 'housing-bridge'); + assert.strictEqual(loaded.parent_causes[0]?.slug, 'homeowners'); + }); }); diff --git a/services/bridge-creator/test/runner.test.ts b/services/bridge-creator/test/runner.test.ts index bd3fe00f..cfaf878a 100644 --- a/services/bridge-creator/test/runner.test.ts +++ b/services/bridge-creator/test/runner.test.ts @@ -36,6 +36,7 @@ function createConfig(): BridgeCreatorConfig { proposalEstimatedOutputTokens: 300, rateLimitWindowMs: 60_000, rateLimitMaxRequests: 10, + parentCauses: [], }; } @@ -230,4 +231,25 @@ describe('runBridgeCreatorTick', () => { assert.notStrictEqual(withProposals.inputHash, withoutProposals.inputHash); }); + + it('publishes a cause-cluster plan when parent causes are configured', async () => { + const plans: unknown[] = []; + const result = await runBridgeCreatorTick({} as SDKMachinery, { + ...createConfig(), + name: 'Ada Mediator', + clusterSlug: 'housing-bridge', + parentCauses: [ + { owner: '0x1111111111111111111111111111111111111111', slug: 'left-camp', side: 'side_a' }, + { owner: '0x2222222222222222222222222222222222222222', slug: 'right-camp', side: 'side_b' }, + ], + }, createDependencies({ + publishTickCluster: async (plan) => { + plans.push(plan.clusterSlug); + }, + })); + + assert.strictEqual(result.status, 'published'); + assert.strictEqual(result.clusterSlug, 'housing-bridge'); + assert.deepStrictEqual(plans, ['housing-bridge']); + }); }); diff --git a/specs/decisions/0012-mediator-is-an-address.md b/specs/decisions/0012-mediator-is-an-address.md new file mode 100644 index 00000000..37ccfe91 --- /dev/null +++ b/specs/decisions/0012-mediator-is-an-address.md @@ -0,0 +1,37 @@ +# 0012. A mediator is an address; human and LLM are authors + +- **Status:** Accepted +- **Date:** 2026-08-20 +- **Related specs:** [`specs/product/bridge-cluster-as-nudger.md`](../product/bridge-cluster-as-nudger.md), [`specs/product/bridge-causes.md`](../product/bridge-causes.md), [`specs/product/bridge-creator.md`](../product/bridge-creator.md), [`specs/product/nudge-ux.md`](../product/nudge-ux.md), [0011](./0011-organizer-contact-is-pull.md) + +## Context + +CauseStarter had two authoring paths that looked like different products. A person could publish a [bridge cluster](../product/bridge-causes.md) of ordinary causes and optionally write parent→modified nudge batches under their wallet. A founder could attach a running `bridge-creator` instance; visitors opted into that *service* (`address` + `serviceUrl`). Cluster pages had no opt-in. The same editorial job — wording of each side that still sounds like that side, shared ground those wordings imply, nudges at the modified wording not the compromise — was split by whether a daemon was running. + +The question was whether a human-written cluster should be a nudger people can subscribe to, with republish as the human’s tick. + +## Decision + +**Users subscribe to a mediator Ethereum address.** Whether that address is driven by a human (edit and republish) or an LLM process (schedule / `GET /anchors`) does not change the listener object. + +**The editorial shape is the same for both authors.** There are two presentations of that shape, and both are available to both kinds of author: + +- **Triples** — statement-level `{ side-a, side-b, common-ground }` (including when there are no parent causes, as in CSM). +- **Causes** — a [bridge cluster](../product/bridge-causes.md): modified cause per natural parent plus a bridge cause. + +Do not couple “triples ↔ LLM service” and “causes ↔ human form.” Do not require a human to stand up `bridge-creator` to be subscribed to. Do not require an LLM to materialize cause pages. Do not auto-subscribe from opening a cluster. Do not collapse authoring runtimes: a human tick is an edit; strategy prompts, beat-agent context, and `GET /anchors` stay properties of the LLM instance. + +## Alternatives considered + +- **Opt-in only on `serviceUrl` mediators.** Rejected: that defines a nudger as a daemon. Human clusters become one-shot brochures; the LLM path is the only durable mediation path. Contradicts [bridge-causes.md](../product/bridge-causes.md) (a person must offer the same opt-in without handing editorial control to an LLM) and the trust model (users trust addresses, not HTTP processes). +- **Pretend the human is the HTTP service** (`GET /anchors` over cause pages, require `bridge-creator` for a one-off cluster). Rejected: extra ops, fake endpoints, and it stretches cause-assist into a standing mediator. +- **Per-cluster subscribe instead of per-address.** Rejected for v1: the on-chain object is already the publishing address; later mute-by-schema can filter batch kinds. Copy must say you are opting into this mediator, not this page. +- **Opening a cluster auto-trusts the mediator.** Rejected: pull, not push ([0011](./0011-organizer-contact-is-pull.md), [nudge-ux.md](../product/nudge-ux.md)). + +## Consequences + +Cluster pages get the same opt-in control as `CauseMediatorCard`, keyed on `mediatorAddress`, without requiring `serviceUrl`. Featured-triple fetch stays a service feature. Attach-a-service remains “this identity also runs a synthesizer.” Cause-assist stays a copy editor. + +Implementation work lives in [bridge-cluster-as-nudger.md](../product/bridge-cluster-as-nudger.md). + +Revisit if listeners cannot tell a one-shot human batch from an always-on synthesizer and that confusion becomes abuse; or if one address mixing cluster batches and service batches needs a mute-by-schema control. Do not revisit “subscribe is to a process” without a new ADR. diff --git a/specs/decisions/README.md b/specs/decisions/README.md index 7f7681fa..9a96286c 100644 --- a/specs/decisions/README.md +++ b/specs/decisions/README.md @@ -59,3 +59,4 @@ instance most needs answered and can't get anywhere else. | [0009](./0009-causes-are-publications-over-statements.md) | Causes are publications over statements | Accepted | | [0010](./0010-combinator-statements.md) | Combinator statements are the graph form of a promoted view | Accepted | | [0011](./0011-organizer-contact-is-pull.md) | Organizer contact is pull, not a message hub | Accepted | +| [0012](./0012-mediator-is-an-address.md) | A mediator is an address; human and LLM are authors | Accepted | diff --git a/specs/glossary.md b/specs/glossary.md index ee563116..0e567c31 100644 --- a/specs/glossary.md +++ b/specs/glossary.md @@ -53,6 +53,7 @@ wrong (or this file is out of date and needs an ADR — see | **Success attestation** | "This project actually delivered" | | **Trust score** | A user's direct trust setting on another user (Subjectiv). Filtering is by *transitive* trust over these | | **Attester / Finder / Nudger** | The three AI-service verbs. An attester judges a pair; a finder discovers pairs worth judging; a nudger proposes new things to the graph. (A fourth, *follower*/context-provider, is being extracted as `beat-memory`) | +| **Mediator** | An Ethereum **address** people opt into for parent→modified (or triple) suggestions. A human (edit/republish) or an LLM process may author behind it; listeners subscribe to the address, not the runtime. See [ADR 0012](./decisions/0012-mediator-is-an-address.md). | Not the HTTP `bridge-creator` process itself; not cause-assist | ### Structure diff --git a/specs/product/README.md b/specs/product/README.md index 38fcfe92..47f34ad2 100644 --- a/specs/product/README.md +++ b/specs/product/README.md @@ -14,7 +14,7 @@ Product-manager-level planning documents. These describe *what* to build and *wh - **[bridge-creator.md](bridge-creator.md)** — Actively synthesizing common-ground statements and getting them in front of people (speculative) - **[bridge-building-for-founders.md](bridge-building-for-founders.md)** — Turning the CSM bridge-creator into a building block any cause founder can adopt ("a mediator for your cause"): what's already generic, the four places CSM-ness actually lives, a tiered plan, and why the beat-agent rehearsal gates it. - **[bridge-causes.md](bridge-causes.md)** — Present a mediator as natural / modified / bridge causes (\(n+1\) publications); human authors can write the cluster without an LLM loop. Does not replace statement-level triples. -- **[bridge-cluster-as-nudger.md](bridge-cluster-as-nudger.md)** — Tentative: one mediator identity people opt into, whether a human published the cluster or an LLM service is synthesizing. Not accepted; do not implement from that file. +- **[bridge-cluster-as-nudger.md](bridge-cluster-as-nudger.md)** — Accepted: users subscribe to a mediator address; triples and cause-clusters are both available to human and LLM authors. Frozen why: [ADR 0012](../decisions/0012-mediator-is-an-address.md). Implementation list is in that file. - **[currency.md](currency.md)** — Currency design: how value moves through the system. - **[privacy-slider.md](privacy-slider.md)** — Thoughts about the "sliding scale" of privacy: how much does a user reveal about himself? - **[new-user-experience.md](new-user-experience.md)** — New-user experience: how exploration and onboarding work, why explorers aren't nudgers. diff --git a/specs/product/bridge-causes.md b/specs/product/bridge-causes.md index 9678ef5c..e2ffe291 100644 --- a/specs/product/bridge-causes.md +++ b/specs/product/bridge-causes.md @@ -66,10 +66,10 @@ Concretely, the product needs a **create / edit bridge** flow (CauseStarter is t 2. Lets the human draft \(C_{im}\) (when not skipped) and \(C\) as normal causes under their own key. 3. Records which plank pairs are meant to be modified→bridge, parent→bridge (stand-in skip), and, where true, modified→parent. 4. Submits those pairs to the implication attester; does not silently invent arrows. -5. Optionally publishes nudge batches pointing parent-signers at the modified planks — the same nudger opt-in as today’s mediator, but the payload can be hand-authored. +5. Optionally publishes nudge batches pointing parent-signers at the modified planks. Opt-in is to the mediator’s address (same object as an attached `bridge-creator`); the payload can be hand-authored. Cluster-page subscribe is [bridge-cluster-as-nudger.md](./bridge-cluster-as-nudger.md). 6. Renders a **bridge cluster page**: the modified causes, the bridge, and links back to the natural parents. -An LLM-powered [bridge-creator](./bridge-creator.md) instance is one *author* of the same objects (subject to today’s operator approval of anchors). It is not the only author. +An LLM-powered [bridge-creator](./bridge-creator.md) instance is one *author* of the same objects (subject to today’s operator approval of anchors). It is not the only author. The listener object is the signer address in either case ([ADR 0012](../decisions/0012-mediator-is-an-address.md)). ## What this does not eat @@ -85,9 +85,9 @@ Featured [anchor clusters](./bridge-creator.md#featured-anchors-the-public-displ Cross-cause “federation” is no longer only “one service suggests wording to another.” The durable join is the bridge cluster. -## Open (not accepted) +## Opt-in -Whether a published cluster should be a **nudger people opt into** — same listener object as an attached `bridge-creator`, human tick = republish — is a tentative idea, not this spec: [bridge-cluster-as-nudger.md](./bridge-cluster-as-nudger.md). +A published cluster is a way to **offer** the existing nudger contract. Visitors subscribe to the cluster’s **mediator address**, not to the page. Human or LLM is the author behind that address. Both statement-level triples and cause-clusters are available to both authors. See [bridge-cluster-as-nudger.md](./bridge-cluster-as-nudger.md) and [ADR 0012](../decisions/0012-mediator-is-an-address.md). ## Deliberately later diff --git a/specs/product/bridge-cluster-as-nudger.md b/specs/product/bridge-cluster-as-nudger.md index 484ab3a6..f329f547 100644 --- a/specs/product/bridge-cluster-as-nudger.md +++ b/specs/product/bridge-cluster-as-nudger.md @@ -1,54 +1,82 @@ -# Tentative: a bridge cluster is a nudger (human or LLM) +# Mediator identity: one address, two presentations, two authors -Status: **tentative** (2026-08-19). Not product direction. Do not implement from this file until Adam rules. Conversation that produced it: human-written CauseStarter clusters vs attached `bridge-creator` services. +Status: **accepted (2026-08-20)**. Frozen why: [ADR 0012](../decisions/0012-mediator-is-an-address.md). -Related accepted specs: [bridge-causes.md](./bridge-causes.md), [bridge-creator.md](./bridge-creator.md), [bridge-building-for-founders.md](./bridge-building-for-founders.md), [nudge-ux.md](./nudge-ux.md). Founder-facing attach path: [mediator-for-your-cause.md](/docs/founder/mediator-for-your-cause.md). +Related: [bridge-causes.md](./bridge-causes.md), [bridge-creator.md](./bridge-creator.md), [bridge-building-for-founders.md](./bridge-building-for-founders.md), [nudge-ux.md](./nudge-ux.md), [mediator-for-your-cause.md](/docs/founder/mediator-for-your-cause.md). -## The itch +This file is the living “what” plus the implementation list. A fresh agent should implement from the list below, not reverse the ADR. -CauseStarter currently presents two authoring paths that feel like different products: +## Decision (short) -1. **Write a bridge** (`/bridge/new`) — a person publishes a cluster of ordinary causes (natural parents, modified causes, shared bridge). Optional parent→modified nudge *batches* exist. There is **no visitor opt-in** on the cluster page. -2. **Attach a standalone mediator** (`/cause/…/mediator`) — point the cause at a running `bridge-creator` instance (name, description, signer, `serviceUrl`). Opt-in trusts that address; featured triples come from `GET /anchors`. +Users **subscribe to a mediator Ethereum address**. Human or LLM is how that address authors; listeners do not care. -The editorial job is the same: write a wording of each side that still sounds like that side, write shared planks those wordings imply, and (if anyone is listening) point parent-signers at the **modified** wording, not at the compromise. The LLM service does that on a schedule from anchors + beat context. A human does it when they publish or edit. +The **job** is the same either way: a wording of each side that still sounds like that side, plus shared ground those wordings imply, and nudges at the **modified** wording, not the compromise. -Watching the discourse and updating *is* still the mediator’s ongoing job if they want to keep mediating. Software does not have to daemonize a human; republishing the cluster / a new batch *is* their tick. +Two **presentations** of that job, both available to both authors: -## Tentative collapse +| Presentation | When | Objects | +|---|---|---| +| **Triples** | Sides may not be causes (CSM, in-cause fault lines) | Statement-level `{ side-a, side-b, common-ground }` | +| **Causes** | Parents are (or can be) causes | [Bridge cluster](./bridge-causes.md): modified cause per natural parent + bridge cause | -Treat **one mediator identity** (an Ethereum address people opt into) as the listener-facing object. The author behind that identity is either: +A human tick is **republish**. An LLM tick is the existing synthesizer schedule. Do not smash those runtimes. -- a human, publishing cluster pages and nudge batches from CauseStarter, or -- a `bridge-creator` process, synthesizing on a schedule and exposing `GET /anchors`. +## Rules that keep the collapse honest -Same pull-based nudger contract either way. Signing stays the user’s choice. +1. **Opt into the address**, labeled as this mediator — not into “this page.” Later batches from the same key show up even if they are a different cluster or a service tick. Say that in the copy. +2. **No auto-trust.** Opening a cluster or a cause is not subscribe. +3. **No `serviceUrl` required** to opt in. Featured triples (`GET /anchors`) stay a service feature. Human clusters must not fake a service. +4. **Nudge path stays parent → modified**, never parent → bridge. +5. **Staleness is the mediator’s problem.** Subscribers see new batches only when the address publishes again. Do not claim a human cluster “watches the discourse.” +6. **Cause-assist stays a copy editor** (brief + one-shot verbs), not the mediator. +7. **Attach-a-service** means “this identity also runs a synthesizer.” It is not a second listener object. +8. **Do not couple form to author.** A human can publish triples without standing up `bridge-creator`. An LLM can publish a cluster without pretending the human path does not exist. -What that would add to today’s human path: **opt in to this cluster’s mediator address** the way `CauseMediatorCard` already opts into a service. Then “your nudgers” would surface later batches when the human republishes — which is not implemented. +## What is already true in code -## Keep separate +- Cluster publish records `mediatorAddress` (the connected wallet). See `causestarter/src/lib/bridgeCluster.ts`. +- `publishParentToModifiedNudges` (`causestarter/src/lib/bridgeNudges.ts`) writes a `schemaVersion` 1 `nudge-batch` under that address onto `NudgePublications` — same path as the service. +- The UI refuses to invent parent→modified pairs. +- `CauseMediatorCard` / `mediatorNudgerFromCause` (`ui/src/shared/nudges/mediatorNudger.ts`) **refuse opt-in without `serviceUrl`**. That is the gap this spec closes for humans. +- `TrustedNudgerEntry.serviceUrl` is already optional in the store (`ui/src/shared/hooks/useTrustedNudgers.ts`). `getMediatorOptInPath` already omits `nudgerServiceUrl` when absent. Tally Settings `?addNudger=` already keys on address. +- Suggestion folding is by trusted **address**, not by HTTP (`specs/tech/subsystems/nudger/README.md`). -Do **not** pretend the human *is* the HTTP service: +The remaining work is **subscribe on the cluster**, **address-only opt-in construction**, and **making both presentations reachable from both authors** — not a new contract. -- No requirement to stand up `bridge-creator` for a one-off cluster. -- No stretching `GET /anchors` around pages that are already causes. -- Cause-assist wording help stays a copy editor (brief + one-shot verbs), not the mediator. -- CSM-style work with no parent causes can stay statement-level triples in the service; this idea is about clusters whose parents are causes. +## Implementation list -Do **not** smash authoring runtimes together: strategy prompt, beat-agent context, and anchor-reflection CLI stay properties of the LLM instance. A human tick is an edit. +Do these in order. After each slice, tests should fail if opt-in still requires a service URL, or if a cluster page has no way to trust `mediatorAddress`. -## What is already the same in code +When a slice is done, delete its bullet here (this spec’s list is the living backlog for this decision). Also delete the pointer in [`TODO.md`](/TODO.md) once the whole list is empty. -- Cluster publish records `mediatorAddress` (the connected wallet). -- `publishParentToModifiedNudges` writes a `schemaVersion` 1 nudge-batch under that address onto `NudgePublications` — same path as the service. -- Nudge path is parent → modified; the UI refuses to invent pairs. +### Slice 1 — Cluster opt-in (the original gap) -The gap is **subscribe**, not **publish**. +- [x] On `/bridge/:owner/:slug` (`causestarter/src/pages/BridgeClusterPage.tsx`), add an opt-in control for `mediatorAddress` equivalent to `CauseMediatorCard`: toggle `addTrustedNudger` / `removeTrustedNudger` in the shared store. Do **not** require `serviceUrl`. Use a name/description from the cluster document (mediator label, title, or a short default). Copy: you are listening to **this mediator**, not bookmarking the page; later suggestions appear if they publish again. (`ClusterMediatorOptIn`) +- [x] Reuse or extend `mediatorNudgerFromCause` so an address + name is enough (`serviceUrl` optional). `serviceMediatorFromCause` still requires a URL for attached-service cards. `CauseMediatorCard` uses the latter. +- [x] Deep link: `clusterMediatorOptInPath` / `getMediatorOptInPath` omit `nudgerServiceUrl` when there is no service. `NudgerSettingsSection` already keys on `addNudger` and treats `nudgerServiceUrl` as optional. +- [x] Tests: `mediatorNudger.test.ts`, `ClusterMediatorOptIn.test.tsx`, `CauseMediatorCard.test.tsx` (still disabled without URL). -## Decision to make +### Slice 2 — Honest labels and later batches -Is a published bridge cluster a first-class nudger people can opt into, including after a human update — with the LLM service as one author of that same object, not a parallel product? +- [x] Suggestion folding is by address (`StatementSuggestions` maps `trustedNudgers` to addresses only). Covered by a test that a trusted entry with no `serviceUrl` is still passed to `getStatementNudges`. +- [x] Human-only cluster entries omit `sourceType` rather than forcing `bridge-creator`. CSM still sets `sourceType: 'bridge-creator'` on its configured mediator. -If yes: add cluster opt-in; keep `/bridge/new` LLM-free; keep attach-a-service as “this identity also runs a synthesizer.” +### Slice 3 — Both presentations, both authors -If no: keep opt-in exclusive to `serviceUrl` mediators, and treat human clusters as pages + one-shot batches that only reach people who already trusted that wallet some other way. +These are product completeness, not required to close slice 1. + +- [x] **Human triples, no HTTP.** `/bridge/triple` publishes side-A / side-B / common-ground statements, parent→modified nudge batches, and modified→common-ground attester pairs under the connected wallet. Opt-in is the same address card. No `GET /anchors`. +- [x] **LLM clusters.** Optional `parent_causes` + `cluster_slug` on the mediator artifact. A tick plans n+1 rosters + a `causestarter.bridge-cluster` document and, when `PUBLISHED_DATA` + `MUTABLE_REF_UPDATER` are set, publishes them under the signer. CSM with no parent causes is unchanged. +- [x] Founder docs: attached-service cards still need address + URL; cluster opt-in is by address alone. [bridge-cluster-wording-help.md](/docs/founder/bridge-cluster-wording-help.md) treats the LLM service as a different **runtime**, same **address**. + +### Out of scope (do not do from this spec) + +- Hosted mediation chat; stretching cause-assist into a standing strategy prompt. +- Auto-subscribe; notifications; message hub ([ADR 0011](../decisions/0011-organizer-contact-is-pull.md)). +- Per-cluster mute (later, if one address mixing batch kinds becomes noisy). +- Requiring `/.well-known/nudger.json` for human publishers. +- Nudging parent-signers straight onto the bridge cause. + +## Decision footer + +Accepted 2026-08-20. [ADR 0012](../decisions/0012-mediator-is-an-address.md). diff --git a/specs/product/bridge-creator.md b/specs/product/bridge-creator.md index edb6739a..4149c5bd 100644 --- a/specs/product/bridge-creator.md +++ b/specs/product/bridge-creator.md @@ -2,7 +2,7 @@ This file describes the mechanism. For the vision behind it — why the CSM bridge creator is best understood as a *mediator*, why it's deliberately opinionated rather than neutral, and what incentive structure it creates for users — see [the CSM mediator doc](/docs/end-user/common-sense-majority/mediator.md). -When the parents are already causes, the same triple can be published as ordinary causes (natural / modified / bridge). That presentation, and the requirement that a *human* can author it without an LLM loop, is [bridge-causes.md](./bridge-causes.md). This file remains the statement-level engine. +When the parents are already causes, the same triple can be published as ordinary causes (natural / modified / bridge). That presentation, and the requirement that a *human* can author it without an LLM loop, is [bridge-causes.md](./bridge-causes.md). Listeners subscribe to the **signer address**, whether a human or this process is authoring ([ADR 0012](../decisions/0012-mediator-is-an-address.md), [bridge-cluster-as-nudger.md](./bridge-cluster-as-nudger.md)). This file remains the statement-level engine and the LLM runtime. ## What it does diff --git a/ui/src/conceptspace/components/StatementSuggestions.test.tsx b/ui/src/conceptspace/components/StatementSuggestions.test.tsx index 94bee571..2b8e2e90 100644 --- a/ui/src/conceptspace/components/StatementSuggestions.test.tsx +++ b/ui/src/conceptspace/components/StatementSuggestions.test.tsx @@ -487,6 +487,24 @@ describe('StatementSuggestions', () => { }) }) + it('folds by address even when a trusted mediator has no serviceUrl', async () => { + vi.mocked(useTrustedNudgers).mockReturnValue([ + { address: VALID_NUDGER_1, name: 'Ada Mediator' }, + ]) + + renderWithRouter( + + ) + + await waitFor(() => { + expect(getStatementNudges).toHaveBeenCalledWith( + mockMachinery, + 'bafyTest123', + [VALID_NUDGER_1] + ) + }) + }) + it('refetches suggestions when statementCid changes', async () => { const { rerender } = renderWithRouter( diff --git a/ui/src/shared/index.ts b/ui/src/shared/index.ts index 183a8e47..cc906a07 100644 --- a/ui/src/shared/index.ts +++ b/ui/src/shared/index.ts @@ -76,7 +76,7 @@ export { usePaymentTokenCurrency } from './currency/usePaymentTokenCurrency' // === nudges/ — dismissed-nudge store + CSM mediator nudger === export { dismissNudge, getDismissedNudges } from './nudges/nudgeStore' export { getCsmMediatorNudger, getTallyMediatorOptInPath } from './nudges/csmMediatorNudger' -export { getMediatorOptInPath, mediatorNudgerFromCause } from './nudges/mediatorNudger' +export { getMediatorOptInPath, mediatorNudgerFromCause, serviceMediatorFromCause } from './nudges/mediatorNudger' export type { CauseMediatorConfig } from './nudges/mediatorNudger' export { MediatorOptInBlock } from './nudges/MediatorOptInBlock' export { BridgeDisplayBlock, buildMediatorBridgeCards, fetchFeaturedMediatorAnchors, useMediatorAnchors } from './mediator/BridgeDisplayBlock' diff --git a/ui/src/shared/nudges/mediatorNudger.test.ts b/ui/src/shared/nudges/mediatorNudger.test.ts index bf8e25e5..7d68b07d 100644 --- a/ui/src/shared/nudges/mediatorNudger.test.ts +++ b/ui/src/shared/nudges/mediatorNudger.test.ts @@ -1,21 +1,63 @@ import { describe, expect, it } from 'vitest' -import { getMediatorOptInPath, mediatorNudgerFromCause } from './mediatorNudger' +import { getMediatorOptInPath, mediatorNudgerFromCause, serviceMediatorFromCause } from './mediatorNudger' + +const address = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' describe('cause mediator reusable configuration', () => { it('takes identity and service location entirely from cause config', () => { const mediator = mediatorNudgerFromCause({ - address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + address, name: 'Housing mediator', description: 'Bridges homeowners and renters.', serviceUrl: 'https://housing.example/mediator/', }) - expect(mediator).toMatchObject({ name: 'Housing mediator', serviceUrl: 'https://housing.example/mediator' }) + expect(mediator).toMatchObject({ + name: 'Housing mediator', + serviceUrl: 'https://housing.example/mediator', + sourceType: 'bridge-creator', + }) const url = new URL(getMediatorOptInPath(mediator!), 'https://tally.example') expect(url.searchParams.get('nudgerName')).toBe('Housing mediator') expect(url.searchParams.get('nudgerServiceUrl')).toBe('https://housing.example/mediator') + expect(url.searchParams.get('nudgerSourceType')).toBe('bridge-creator') + }) + + it('accepts an address and name with no service URL (human cluster publisher)', () => { + const mediator = mediatorNudgerFromCause({ + address, + name: 'Ada Mediator', + description: 'Hand-authored settlement.', + }) + expect(mediator).toEqual({ + address, + name: 'Ada Mediator', + description: 'Hand-authored settlement.', + }) + expect(mediator?.serviceUrl).toBeUndefined() + expect(mediator?.sourceType).toBeUndefined() + const url = new URL(getMediatorOptInPath(mediator!), 'https://tally.example') + expect(url.searchParams.get('addNudger')).toBe(address) + expect(url.searchParams.get('nudgerName')).toBe('Ada Mediator') + expect(url.searchParams.has('nudgerServiceUrl')).toBe(false) + expect(url.searchParams.has('nudgerSourceType')).toBe(false) }) it('rejects incomplete or invalid cause identity rather than inventing one', () => { expect(mediatorNudgerFromCause({ address: 'bad', name: 'X', description: 'Y', serviceUrl: 'https://x.example' })).toBeNull() + expect(mediatorNudgerFromCause({ address, name: ' ', description: 'Y' })).toBeNull() + }) + + it('serviceMediatorFromCause still requires a live service URL', () => { + expect(serviceMediatorFromCause({ + address, + name: 'Ada Mediator', + description: 'Hand-authored settlement.', + })).toBeNull() + expect(serviceMediatorFromCause({ + address, + name: 'Housing mediator', + description: 'Bridges homeowners and renters.', + serviceUrl: 'https://housing.example/mediator', + })).toMatchObject({ serviceUrl: 'https://housing.example/mediator' }) }) }) diff --git a/ui/src/shared/nudges/mediatorNudger.ts b/ui/src/shared/nudges/mediatorNudger.ts index 746f7a15..2285abb2 100644 --- a/ui/src/shared/nudges/mediatorNudger.ts +++ b/ui/src/shared/nudges/mediatorNudger.ts @@ -4,23 +4,41 @@ export interface CauseMediatorConfig { address: string name: string description: string - serviceUrl: string + serviceUrl?: string sourceType?: string version?: string } +/** + * Listener object for a mediator address. `serviceUrl` is optional: a human + * cluster publisher has an address but no HTTP service ([ADR 0012](/specs/decisions/0012-mediator-is-an-address.md)). + */ export function mediatorNudgerFromCause(config: CauseMediatorConfig | null | undefined): TrustedNudgerEntry | null { - if (!config || !isValidNudgerAddress(config.address) || !config.name.trim() || !config.description.trim() || !config.serviceUrl.trim()) { + if (!config || !isValidNudgerAddress(config.address) || !config.name.trim()) { return null } - return { + const serviceUrl = config.serviceUrl?.trim().replace(/\/+$/, '') || undefined + const entry: TrustedNudgerEntry = { address: config.address, name: config.name.trim(), - description: config.description.trim(), - serviceUrl: config.serviceUrl.replace(/\/+$/, ''), - sourceType: config.sourceType ?? 'bridge-creator', - version: config.version, } + const description = config.description.trim() + if (description) entry.description = description + if (serviceUrl) { + entry.serviceUrl = serviceUrl + entry.sourceType = config.sourceType ?? 'bridge-creator' + } else if (config.sourceType) { + entry.sourceType = config.sourceType + } + if (config.version) entry.version = config.version + return entry +} + +/** Attached synthesizer: featured triples need a live `serviceUrl`. */ +export function serviceMediatorFromCause(config: CauseMediatorConfig | null | undefined): TrustedNudgerEntry | null { + const entry = mediatorNudgerFromCause(config) + if (!entry?.serviceUrl) return null + return entry } export function getMediatorOptInPath(mediator: TrustedNudgerEntry): string { @@ -28,8 +46,8 @@ export function getMediatorOptInPath(mediator: TrustedNudgerEntry): string { addNudger: mediator.address, nudgerName: mediator.name ?? 'Cause mediator', nudgerDescription: mediator.description ?? 'Suggests bridge statements for this cause.', - nudgerSourceType: mediator.sourceType ?? 'bridge-creator', }) + if (mediator.sourceType) params.set('nudgerSourceType', mediator.sourceType) if (mediator.serviceUrl) params.set('nudgerServiceUrl', mediator.serviceUrl) if (mediator.version) params.set('nudgerVersion', mediator.version) return `/settings?${params.toString()}` From 8226927e36deb7d9ba1c1f1796af6960a4c6acf1 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 20:33:19 -0400 Subject: [PATCH 2/3] Persist cluster authorship on LLM tick rosters. Save publication dedup only after cluster write succeeds, and include bridgeCluster extras so CauseStarter can label modified and bridge causes. --- .../bridge-creator/src/clusterFromTick.ts | 18 +++++++++++++++ services/bridge-creator/src/runner.ts | 10 ++++---- .../test/clusterFromTick.test.ts | 7 ++++++ services/bridge-creator/test/runner.test.ts | 23 +++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/services/bridge-creator/src/clusterFromTick.ts b/services/bridge-creator/src/clusterFromTick.ts index 2ce4db09..0b29a4f8 100644 --- a/services/bridge-creator/src/clusterFromTick.ts +++ b/services/bridge-creator/src/clusterFromTick.ts @@ -29,6 +29,8 @@ export interface ClusterRosterPlan { parentOwner?: `0x${string}`; parentSlug?: string; role: 'modified' | 'bridge'; + clusterOwner: `0x${string}`; + clusterSlug: string; } export interface ClusterDocumentPlan { @@ -95,6 +97,8 @@ export function planClusterFromTick(args: { parentOwner: parent.owner.toLowerCase() as `0x${string}`, parentSlug: parent.slug, role: 'modified', + clusterOwner: owner, + clusterSlug, }); modified.push({ owner, @@ -113,6 +117,8 @@ export function planClusterFromTick(args: { parentOwner: parent.owner.toLowerCase() as `0x${string}`, parentSlug: parent.slug, role: 'modified', + clusterOwner: owner, + clusterSlug, }); modified.push({ owner, @@ -129,6 +135,8 @@ export function planClusterFromTick(args: { summary: 'Bridge cause implied by each modified wording.', plankCids: bridgePlanks, role: 'bridge', + clusterOwner: owner, + clusterSlug, }); const pairs: ClusterDocumentPlan['pairs'] = args.triples.flatMap((triple) => [ @@ -156,6 +164,15 @@ export function planClusterFromTick(args: { } export function rosterDocumentFromPlan(plan: ClusterRosterPlan): Record { + const bridgeCluster: Record = { + clusterOwner: plan.clusterOwner, + clusterSlug: plan.clusterSlug, + role: plan.role, + }; + if (plan.role === 'modified' && plan.parentOwner && plan.parentSlug) { + bridgeCluster.parentOwner = plan.parentOwner; + bridgeCluster.parentSlug = plan.parentSlug; + } return { format: 'markdown-restricted', content: `# ${plan.title}\n\n${plan.summary}`, @@ -168,6 +185,7 @@ export function rosterDocumentFromPlan(plan: ClusterRosterPlan): Record 0 && dependencies.publishTickCluster) { const mediatorAddress = createNudgerSigner(config).address as `0x${string}`; @@ -168,6 +163,11 @@ export async function runBridgeCreatorTick( } } + dependencies.saveDedupState(config.publicationDedupStatePath, { + lastInputHash: inputHash, + lastPublicationSummary: summarizePublishedBridgeTriples(triples), + }); + return { status: 'published', synthesizedBridgeCount: triples.length, diff --git a/services/bridge-creator/test/clusterFromTick.test.ts b/services/bridge-creator/test/clusterFromTick.test.ts index 7e78a3b8..e1cdc9c4 100644 --- a/services/bridge-creator/test/clusterFromTick.test.ts +++ b/services/bridge-creator/test/clusterFromTick.test.ts @@ -51,5 +51,12 @@ describe('planClusterFromTick', () => { const rosterDoc = rosterDocumentFromPlan(plan.rosters[0]!); assert.strictEqual((rosterDoc.extras as { kind: string }).kind, ROSTER_KIND); assert.deepStrictEqual((rosterDoc.extras as { plankCids: string[] }).plankCids, ['bafymoda']); + assert.deepStrictEqual((rosterDoc.extras as { bridgeCluster: Record }).bridgeCluster, { + clusterOwner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + clusterSlug: 'housing-bridge', + role: 'modified', + parentOwner: parentA.owner, + parentSlug: parentA.slug, + }); }); }); diff --git a/services/bridge-creator/test/runner.test.ts b/services/bridge-creator/test/runner.test.ts index cfaf878a..80e12eab 100644 --- a/services/bridge-creator/test/runner.test.ts +++ b/services/bridge-creator/test/runner.test.ts @@ -252,4 +252,27 @@ describe('runBridgeCreatorTick', () => { assert.strictEqual(result.clusterSlug, 'housing-bridge'); assert.deepStrictEqual(plans, ['housing-bridge']); }); + + it('does not persist dedup if cluster publication throws', async () => { + let saved = false; + await assert.rejects( + () => runBridgeCreatorTick({} as SDKMachinery, { + ...createConfig(), + clusterSlug: 'housing-bridge', + parentCauses: [ + { owner: '0x1111111111111111111111111111111111111111', slug: 'left-camp', side: 'side_a' }, + { owner: '0x2222222222222222222222222222222222222222', slug: 'right-camp', side: 'side_b' }, + ], + }, createDependencies({ + publishTickCluster: async () => { + throw new Error('cluster write failed'); + }, + saveDedupState: () => { + saved = true; + }, + })), + /cluster write failed/, + ); + assert.strictEqual(saved, false); + }); }); From 5ab04b54cf05a41050c2ffe2913d14ea3a769012 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Thu, 20 Aug 2026 20:40:29 -0400 Subject: [PATCH 3/3] Keep LLM cluster slugs valid after 64-char truncation. Strip trailing hyphens after slice, uniquify colliding parent slugs, and build cause-mediator deep links only from a complete service identity. --- causestarter/TODO.md | 2 ++ .../src/components/CauseMediatorCard.test.tsx | 5 +++ .../src/components/CauseMediatorCard.tsx | 12 +++---- .../bridge-creator/src/clusterFromTick.ts | 30 +++++++++++++++--- .../test/clusterFromTick.test.ts | 31 +++++++++++++++++++ 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 1fae289e..10e69081 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -7,6 +7,8 @@ open **if they stay listed here**. ## Product / UX +- [ ] `normalizeSlug` slices to 64 *after* stripping hyphens, so a cut on a hyphen can fail `validateSlug`. Bridge-creator `slugifyCluster` now strips again after slice; align CauseStarter if organizers hit 64-char slugs. + - [x] **Cluster-page mediator opt-in** and **statement-level triples** (`/bridge/triple`) — [ADR 0012](/specs/decisions/0012-mediator-is-an-address.md). - [ ] **Content contracts on the cause board — leftover after first slice.** diff --git a/causestarter/src/components/CauseMediatorCard.test.tsx b/causestarter/src/components/CauseMediatorCard.test.tsx index 62c88aab..53947913 100644 --- a/causestarter/src/components/CauseMediatorCard.test.tsx +++ b/causestarter/src/components/CauseMediatorCard.test.tsx @@ -88,6 +88,11 @@ describe('CauseMediatorCard', () => { it('still offers a deep link for clients that cannot toggle in place', () => { const path = causeMediatorOptInPath(mediator) expect(path).toContain('nudgerName=Housing+mediator') + expect(path).toContain('nudgerServiceUrl=https%3A%2F%2Fhousing.example%2Fmediator') expect(path).not.toContain('Common+Sense+Majority') }) + + it('does not deep-link an incomplete mediator into settings', () => { + expect(causeMediatorOptInPath({ ...mediator, serviceUrl: '' })).toBe('/settings') + }) }) diff --git a/causestarter/src/components/CauseMediatorCard.tsx b/causestarter/src/components/CauseMediatorCard.tsx index e110296a..15a66aa8 100644 --- a/causestarter/src/components/CauseMediatorCard.tsx +++ b/causestarter/src/components/CauseMediatorCard.tsx @@ -4,6 +4,7 @@ import CheckIcon from '@mui/icons-material/Check' import { Link as RouterLink } from 'react-router-dom' import { addTrustedNudger, + getMediatorOptInPath, isTrustedNudger, loadTrustedNudgers, serviceMediatorFromCause, @@ -16,14 +17,9 @@ import type { CauseMediator } from '../lib/causeStore' * place). CauseStarter reads the same store directly, so its own card toggles. */ export function causeMediatorOptInPath(mediator: CauseMediator): string { - const params = new URLSearchParams({ - addNudger: mediator.address, - nudgerName: mediator.name, - nudgerDescription: mediator.description, - nudgerServiceUrl: mediator.serviceUrl, - nudgerSourceType: 'bridge-creator', - }) - return `/settings?${params.toString()}` + const entry = serviceMediatorFromCause(mediator) + if (!entry) return '/settings' + return getMediatorOptInPath(entry) } /** diff --git a/services/bridge-creator/src/clusterFromTick.ts b/services/bridge-creator/src/clusterFromTick.ts index 0b29a4f8..e3557f9f 100644 --- a/services/bridge-creator/src/clusterFromTick.ts +++ b/services/bridge-creator/src/clusterFromTick.ts @@ -55,16 +55,37 @@ export interface TickClusterPlan { cluster: ClusterDocumentPlan; } +const MAX_SLUG_LENGTH = 64; + export function slugifyCluster(raw: string): string { const slug = raw .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') - .slice(0, 64); + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/g, ''); return slug || 'bridge-cluster'; } +function uniqueRosterSlug(raw: string, used: Set): string { + const base = slugifyCluster(raw); + if (!used.has(base)) { + used.add(base); + return base; + } + for (let n = 2; n < 1000; n += 1) { + const suffix = `-${n}`; + const truncated = base.slice(0, Math.max(1, MAX_SLUG_LENGTH - suffix.length)).replace(/-+$/g, ''); + const candidate = slugifyCluster(`${truncated}${suffix}`); + if (!used.has(candidate)) { + used.add(candidate); + return candidate; + } + } + throw new Error(`Could not uniquify roster slug from "${raw}"`); +} + export function planClusterFromTick(args: { mediatorName: string; mediatorNote: string; @@ -86,9 +107,10 @@ export function planClusterFromTick(args: { const rosters: ClusterRosterPlan[] = []; const modified: ClusterDocumentPlan['modified'] = []; + const usedSlugs = new Set([clusterSlug]); for (const parent of sideAParents) { - const slug = slugifyCluster(`${clusterSlug}-${parent.slug}-modified`); + const slug = uniqueRosterSlug(`${clusterSlug}-${parent.slug}-modified`, usedSlugs); rosters.push({ slug, title: `${args.mediatorName}: ${parent.slug} (modified)`, @@ -108,7 +130,7 @@ export function planClusterFromTick(args: { }); } for (const parent of sideBParents) { - const slug = slugifyCluster(`${clusterSlug}-${parent.slug}-modified`); + const slug = uniqueRosterSlug(`${clusterSlug}-${parent.slug}-modified`, usedSlugs); rosters.push({ slug, title: `${args.mediatorName}: ${parent.slug} (modified)`, @@ -128,7 +150,7 @@ export function planClusterFromTick(args: { }); } - const bridgeSlug = slugifyCluster(`${clusterSlug}-bridge`); + const bridgeSlug = uniqueRosterSlug(`${clusterSlug}-bridge`, usedSlugs); rosters.push({ slug: bridgeSlug, title: `${args.mediatorName}: shared ground`, diff --git a/services/bridge-creator/test/clusterFromTick.test.ts b/services/bridge-creator/test/clusterFromTick.test.ts index e1cdc9c4..88989add 100644 --- a/services/bridge-creator/test/clusterFromTick.test.ts +++ b/services/bridge-creator/test/clusterFromTick.test.ts @@ -5,6 +5,7 @@ import { clusterDocumentFromPlan, planClusterFromTick, rosterDocumentFromPlan, + slugifyCluster, } from '../src/clusterFromTick.js'; const parentA = { @@ -59,4 +60,34 @@ describe('planClusterFromTick', () => { parentSlug: parentA.slug, }); }); + + it('slugifyCluster does not leave a trailing hyphen after 64-char truncation', () => { + const slug = slugifyCluster(`${'a'.repeat(60)}-modified`); + assert.ok(!slug.endsWith('-')); + assert.ok(slug.length <= 64); + assert.match(slug, /^[a-z0-9]+(?:-[a-z0-9]+)*$/); + }); + + it('uniquifies modified slugs when two long parent slugs would collide', () => { + const longA = 'x'.repeat(64); + const longB = 'x'.repeat(63) + 'y'; + const plan = planClusterFromTick({ + mediatorName: 'Ada Mediator', + mediatorNote: '', + mediatorAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + clusterSlug: 'c', + parentCauses: [ + { ...parentA, slug: longA }, + { ...parentB, slug: longB }, + ], + triples: [{ sideACid: 'a', sideBCid: 'b', commonGroundCid: 'c' }], + }); + assert.ok(plan); + const slugs = plan.rosters.map((roster) => roster.slug); + assert.strictEqual(new Set(slugs).size, slugs.length); + for (const slug of slugs) { + assert.ok(!slug.endsWith('-')); + assert.match(slug, /^[a-z0-9]+(?:-[a-z0-9]+)*$/); + } + }); });