diff --git a/causestarter/TODO.md b/causestarter/TODO.md
index 29df9024..10e69081 100644
--- a/causestarter/TODO.md
+++ b/causestarter/TODO.md
@@ -7,6 +7,10 @@ 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.**
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..53947913 100644
--- a/causestarter/src/components/CauseMediatorCard.test.tsx
+++ b/causestarter/src/components/CauseMediatorCard.test.tsx
@@ -78,9 +78,21 @@ 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')
+ 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 2377637d..15a66aa8 100644
--- a/causestarter/src/components/CauseMediatorCard.tsx
+++ b/causestarter/src/components/CauseMediatorCard.tsx
@@ -4,9 +4,10 @@ import CheckIcon from '@mui/icons-material/Check'
import { Link as RouterLink } from 'react-router-dom'
import {
addTrustedNudger,
+ getMediatorOptInPath,
isTrustedNudger,
loadTrustedNudgers,
- mediatorNudgerFromCause,
+ serviceMediatorFromCause,
removeTrustedNudger,
} from '@ui/shared'
import type { CauseMediator } from '../lib/causeStore'
@@ -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)
}
/**
@@ -38,7 +34,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.
+
+
+ : undefined}
+ aria-pressed={optedIn}
+ data-testid="cluster-mediator-optin"
+ sx={{ textTransform: 'none', borderRadius: 999, flexShrink: 0 }}
+ >
+ {optedIn ? 'Opted in' : 'Opt in'}
+
+
+
+ )
+}
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;
+}
+
+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, 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;
+ 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'] = [];
+ const usedSlugs = new Set([clusterSlug]);
+
+ for (const parent of sideAParents) {
+ const slug = uniqueRosterSlug(`${clusterSlug}-${parent.slug}-modified`, usedSlugs);
+ 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',
+ clusterOwner: owner,
+ clusterSlug,
+ });
+ modified.push({
+ owner,
+ slug,
+ parentOwner: parent.owner.toLowerCase() as `0x${string}`,
+ parentSlug: parent.slug,
+ });
+ }
+ for (const parent of sideBParents) {
+ const slug = uniqueRosterSlug(`${clusterSlug}-${parent.slug}-modified`, usedSlugs);
+ 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',
+ clusterOwner: owner,
+ clusterSlug,
+ });
+ modified.push({
+ owner,
+ slug,
+ parentOwner: parent.owner.toLowerCase() as `0x${string}`,
+ parentSlug: parent.slug,
+ });
+ }
+
+ const bridgeSlug = uniqueRosterSlug(`${clusterSlug}-bridge`, usedSlugs);
+ rosters.push({
+ slug: bridgeSlug,
+ title: `${args.mediatorName}: shared ground`,
+ summary: 'Bridge cause implied by each modified wording.',
+ plankCids: bridgePlanks,
+ role: 'bridge',
+ clusterOwner: owner,
+ clusterSlug,
+ });
+
+ 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 {
+ 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}`,
+ 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: '',
+ bridgeCluster,
+ },
+ };
+}
+
+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..59f36c25 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 = {
@@ -138,6 +142,27 @@ export async function runBridgeCreatorTick(
)
: [];
+ 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;
+ }
+ }
+
dependencies.saveDedupState(config.publicationDedupStatePath, {
lastInputHash: inputHash,
lastPublicationSummary: summarizePublishedBridgeTriples(triples),
@@ -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..88989add
--- /dev/null
+++ b/services/bridge-creator/test/clusterFromTick.test.ts
@@ -0,0 +1,93 @@
+import assert from 'node:assert';
+import {
+ BRIDGE_CLUSTER_KIND,
+ ROSTER_KIND,
+ clusterDocumentFromPlan,
+ planClusterFromTick,
+ rosterDocumentFromPlan,
+ slugifyCluster,
+} 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']);
+ assert.deepStrictEqual((rosterDoc.extras as { bridgeCluster: Record }).bridgeCluster, {
+ clusterOwner: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ clusterSlug: 'housing-bridge',
+ role: 'modified',
+ parentOwner: parentA.owner,
+ 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]+)*$/);
+ }
+ });
+});
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..80e12eab 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,48 @@ 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']);
+ });
+
+ 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);
+ });
});
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()}`