From fdaa61818694ccbf429e111779209a4ecca74d49 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Fri, 28 Aug 2026 10:43:13 -0400 Subject: [PATCH 1/6] Paint combinator statement pages before operand IPFS reads finish. Operand bodies fill in with CID fallbacks instead of blocking the whole page spinner on Promise.all. --- TODO.md | 9 -- inbox.md | 2 + .../causestarter/pages/StatementPage.test.tsx | 90 +++++++++++++++++++ ui/src/causestarter/pages/StatementPage.tsx | 16 ++-- .../conceptspace/pages/StatementPage.test.tsx | 67 +++++++++++++- ui/src/conceptspace/pages/StatementPage.tsx | 19 ++-- 6 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 ui/src/causestarter/pages/StatementPage.test.tsx diff --git a/TODO.md b/TODO.md index 63e75919..8485cfce 100644 --- a/TODO.md +++ b/TODO.md @@ -56,15 +56,6 @@ When an item from this page is done and no longer needs an LLM implementor's att - Give the demo seed (`./scripts/data.sh --seed=demo`) more **local public-goods** coverage. One storyline now exists (see above), but rows A5 (federated regional) and E2 (nonprofit on the rails) in [use-cases.md](specs/product/use-cases.md) are still not demonstrable — and those are exactly the cases the strategy docs lean on hardest. Note also that the project-creation form ships "Community garden" / "Clean water" / "Learning circle" stock images that nothing in the seed uses. Found 2026-07-25 while verifying use-case statuses against the live UI. -- Stop combinator operand reads from holding up the whole statement page. Both - `causestarter/src/pages/StatementPage.tsx` and - `ui/src/conceptspace/pages/StatementPage.tsx` fetch every operand body of a - combinator statement *before* clearing `loading`, so one slow IPFS read leaves the - reader staring at a spinner even though the statement's own content already - resolved. Paint the statement first and let the operand bodies fill in (they - already fall back to showing the CID), rather than blocking on `Promise.all`. - Found 2026-08-19 reviewing the combinator-statements branch. - - Guard the rest of `ui/src/conceptspace/pages/StatementPage.tsx`'s loader against navigation. The operand fetch now checks a load token before writing, but the earlier `setStatement` / `setStatementContent` / `setContentStatus` / metrics / diff --git a/inbox.md b/inbox.md index 93b5aca3..ef3f81c3 100644 --- a/inbox.md +++ b/inbox.md @@ -17,6 +17,8 @@ Also, don't let any of the items get too long; usually there's a separate .md fi ## Main list +- **(Tell)** Combinator statement pages no longer wait on operand IPFS reads before painting. CauseStarter and Conceptspace `StatementPage`s show the combinator (CID fallbacks) immediately; operand bodies fill in as they resolve. Navigation-stale writes on the rest of Conceptspace's loader are still unguarded (separate TODO). + - **(Tell)** Production OpenRouter services (attesters, service-host, cause-assist, coherence-badge-worker) now default to `deepseek/deepseek-v4-flash-0731` via `PRODUCTION_OPENROUTER_MODEL`. Laptop scripts use the same id through a separate `DEV_OPENROUTER_MODEL` env / `fake-data-generation/devOpenRouter.ts`. Cause-assist prefers OpenRouter over xAI when both keys exist. Update Render dashboard if those env vars were set by hand. - **(Tell)** Statement-generation exercise 2: first attester pass refused modified-right → commonality (no cutoff). Thickened modified-right on [hidden-majority-patterns.md](docs/end-user/common-sense-majority/hidden-majority-patterns.md) (also bridge-creator + exercise JSON). Re-run: both modifieds → commonality yes/high; both naturals → commonality no/high. Still not in `seed-content/`; `/critique-triple` not run. diff --git a/ui/src/causestarter/pages/StatementPage.test.tsx b/ui/src/causestarter/pages/StatementPage.test.tsx new file mode 100644 index 00000000..33c7d71a --- /dev/null +++ b/ui/src/causestarter/pages/StatementPage.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createCombinatorStatement } from '@commonality/sdk/displayable-documents' +import { StatementPage } from './StatementPage' + +const getStatementWithContent = vi.fn() + +vi.mock('@commonality/sdk/conceptspace', () => ({ + getStatementWithContent: (...args: unknown[]) => getStatementWithContent(...args), +})) + +vi.mock('@ui/shared', () => ({ + useMachinery: () => ({}), + useTrustedAttesters: () => [], +})) + +vi.mock('../hooks/useAlignmentTrust', () => ({ + useAlignmentTrust: () => ({ trustedAlignmentAttesters: new Set() }), +})) + +vi.mock('../hooks/useViewCounts', () => ({ + useViewCounts: () => ({ + perPlank: new Map(), + loading: false, + refresh: vi.fn(), + }), +})) + +vi.mock('../components/SupportButton', () => ({ + SupportButton: () => , +})) + +vi.mock('../components/CauseFundingSummary', () => ({ + CauseFundingSummary: () => null, +})) + +vi.mock('@ui/fundingportals', () => ({ + CauseBoard: () => null, + CauseLeaderboard: () => null, +})) + +vi.mock('../components/StarterNetworkFilterNotice', () => ({ + StarterNetworkFilterCopy: () => null, +})) + +describe('StatementPage combinator operands', () => { + const operandA = 'bafyoperandaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const operandB = 'bafyoperandbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + + beforeEach(() => { + vi.clearAllMocks() + }) + + function renderPage() { + return render( + + + } /> + + , + ) + } + + it('shows the combinator statement before operand bodies resolve', async () => { + const combinatorContent = createCombinatorStatement('all', [operandA, operandB]) + getStatementWithContent.mockImplementation(async (_machinery: unknown, cid: string) => { + if (cid === 'stmt123') { + return { + statement: { + cid: 'stmt123', + believerCount: 1, + title: 'All of these', + }, + content: combinatorContent, + } + } + return new Promise(() => {}) + }) + + renderPage() + + await waitFor(() => { + expect(screen.getByTestId('combinator-operands')).toBeInTheDocument() + }) + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + expect(screen.getByTestId('combinator-operands')).toHaveTextContent(operandA) + expect(screen.getByTestId('combinator-operands')).toHaveTextContent(operandB) + }) +}) diff --git a/ui/src/causestarter/pages/StatementPage.tsx b/ui/src/causestarter/pages/StatementPage.tsx index cfcdc9e5..c1bf84c4 100644 --- a/ui/src/causestarter/pages/StatementPage.tsx +++ b/ui/src/causestarter/pages/StatementPage.tsx @@ -84,16 +84,22 @@ export function StatementPage() { setContent(result.content) const combinator = result.content ? parseCombinatorStatement(result.content) : null if (combinator) { - const operands = await Promise.all(combinator.operandCids.map(async (cid) => { + // Paint CID fallbacks immediately so the statement page is not held + // behind operand IPFS reads; fill each body as it arrives. + setOperandBodies(combinator.operandCids.map((cid) => ({ cid, text: cid }))) + void Promise.all(combinator.operandCids.map(async (cid) => { + let text = cid try { const operand = await getStatementWithContent(machinery, cid as IpfsCidV1) - return { cid, text: documentText(operand?.content) || cid } + text = documentText(operand?.content) || cid } catch { - return { cid, text: cid } + text = cid } + if (cancelled()) return + setOperandBodies((prev) => + prev.map((row) => (row.cid === cid ? { cid, text } : row)), + ) })) - if (cancelled()) return - setOperandBodies(operands) } else { setOperandBodies([]) } diff --git a/ui/src/conceptspace/pages/StatementPage.test.tsx b/ui/src/conceptspace/pages/StatementPage.test.tsx index 13e09b2f..5a703e5c 100644 --- a/ui/src/conceptspace/pages/StatementPage.test.tsx +++ b/ui/src/conceptspace/pages/StatementPage.test.tsx @@ -32,11 +32,18 @@ vi.mock('@commonality/sdk/machinery', async () => { // Mock child components vi.mock('../components/StatementRenderer', () => ({ - StatementRenderer: vi.fn(({ statementCid, content, error }) => ( + StatementRenderer: vi.fn(({ statementCid, content, error, referencedDocuments }) => (
StatementRenderer: {statementCid} {content &&
Content present
} {error &&
Error: {error}
} + {referencedDocuments && Object.keys(referencedDocuments).length > 0 && ( +
+ {Object.entries(referencedDocuments).map(([cid, doc]) => ( +
{cid}:{(doc as { content?: string } | null)?.content ?? 'pending'}
+ ))} +
+ )}
)), })) @@ -83,6 +90,7 @@ vi.mock('../../content-funding/components/ContentSubmissionForm', () => ({ import { useParams } from 'react-router-dom' import { useAccount } from 'wagmi' import { getStatementWithContent, getUserBelief } from '@commonality/sdk/conceptspace' +import { createCombinatorStatement } from '@commonality/sdk/displayable-documents' import { createSDKMachinery } from '@commonality/sdk/machinery' describe('StatementPage', () => { @@ -647,4 +655,61 @@ describe('StatementPage', () => { }) }) }) + + describe('Combinator operand loading', () => { + const operandA = 'bafyoperandaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + const operandB = 'bafyoperandbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + + it('paints the combinator statement before operand bodies resolve', async () => { + const combinatorContent = createCombinatorStatement('all', [operandA, operandB]) + vi.mocked(useParams).mockReturnValue({ statementCid: 'stmt123' }) + vi.mocked(getStatementWithContent).mockImplementation(async (_machinery, cid) => { + if (String(cid) === 'stmt123') { + return { + statement: mockStatement, + content: combinatorContent, + contentStatus: 'active' as const, + metrics: undefined, + } + } + return new Promise(() => {}) + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('statement-renderer')).toBeInTheDocument() + }) + expect(screen.queryByTestId('referenced-documents')).not.toBeInTheDocument() + }) + + it('fills operand bodies as they resolve', async () => { + const combinatorContent = createCombinatorStatement('all', [operandA, operandB]) + vi.mocked(useParams).mockReturnValue({ statementCid: 'stmt123' }) + vi.mocked(getStatementWithContent).mockImplementation(async (_machinery, cid) => { + if (String(cid) === 'stmt123') { + return { + statement: mockStatement, + content: combinatorContent, + contentStatus: 'active' as const, + metrics: undefined, + } + } + return { + statement: { ...mockStatement, cid: cid as `b${string}` }, + content: { format: 'text/plain' as const, title: String(cid), content: `body of ${cid}` }, + contentStatus: 'active' as const, + metrics: undefined, + } + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('referenced-documents')).toBeInTheDocument() + }) + expect(screen.getByTestId('referenced-documents').textContent).toContain(`body of ${operandA}`) + expect(screen.getByTestId('referenced-documents').textContent).toContain(`body of ${operandB}`) + }) + }) }) diff --git a/ui/src/conceptspace/pages/StatementPage.tsx b/ui/src/conceptspace/pages/StatementPage.tsx index ee9c98f8..509b4a02 100644 --- a/ui/src/conceptspace/pages/StatementPage.tsx +++ b/ui/src/conceptspace/pages/StatementPage.tsx @@ -72,22 +72,21 @@ export function StatementPage() { setContentStatus(result.contentStatus) const combinator = result.content ? parseCombinatorStatement(result.content) : null + setReferencedDocuments({}) if (combinator) { - const operands: Record = {} - await Promise.all(combinator.operandCids.map(async (cid) => { + // Operand bodies fill in after the page paints; the renderer already + // falls back to the CID until each read resolves. + void Promise.all(combinator.operandCids.map(async (cid) => { + let body: DisplayableDocument | null = null try { const operand = await getStatementWithContent(machinery, cid as IpfsCidV1) - operands[cid] = operand?.content ?? null + body = operand?.content ?? null } catch { - operands[cid] = null + body = null } + if (loadToken !== loadTokenRef.current) return + setReferencedDocuments((prev) => ({ ...prev, [cid]: body })) })) - // Operand reads outlive a navigation; a late resolve must not paint one - // statement's operands onto another. - if (loadToken !== loadTokenRef.current) return - setReferencedDocuments(operands) - } else { - setReferencedDocuments({}) } if (!result.content && result.statement.cid) { From ecb312b954e9cbfbd1d17a9cf8bc6c2ba9030922 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Fri, 28 Aug 2026 11:07:13 -0400 Subject: [PATCH 2/6] Guard conceptspace page loaders against stale writes after navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatementPage: extend the existing loadTokenRef guard (previously only covering operand fetches) to all state writes after await points — setStatement, setStatementContent, setContentStatus, metrics, setUserBeliefState, and the catch block. A slow load that resolves after the user has navigated to a different statement no longer paints stale content. Sweep the same pattern across the other conceptspace pages: - UserProfilePage: guard against stale writes when navigating between profiles (address param change) - BrowseStatementsPage: guard against stale writes when rapidly toggling sort options All 112 existing tests pass. --- TODO.md | 7 ------- ui/src/conceptspace/pages/BrowseStatementsPage.tsx | 12 +++++++++++- ui/src/conceptspace/pages/StatementPage.tsx | 7 ++++++- ui/src/conceptspace/pages/UserProfilePage.tsx | 12 +++++++++++- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 8485cfce..7bcdac32 100644 --- a/TODO.md +++ b/TODO.md @@ -56,10 +56,3 @@ When an item from this page is done and no longer needs an LLM implementor's att - Give the demo seed (`./scripts/data.sh --seed=demo`) more **local public-goods** coverage. One storyline now exists (see above), but rows A5 (federated regional) and E2 (nonprofit on the rails) in [use-cases.md](specs/product/use-cases.md) are still not demonstrable — and those are exactly the cases the strategy docs lean on hardest. Note also that the project-creation form ships "Community garden" / "Clean water" / "Learning circle" stock images that nothing in the seed uses. Found 2026-07-25 while verifying use-case statuses against the live UI. -- Guard the rest of `ui/src/conceptspace/pages/StatementPage.tsx`'s loader against - navigation. The operand fetch now checks a load token before writing, but the - earlier `setStatement` / `setStatementContent` / `setContentStatus` / metrics / - `setUserBeliefState` writes are still unguarded, so a slow load that resolves after - the user has moved to another statement can paint stale content. Pre-existing, not - new to the combinator work; the same pattern is worth a sweep across the other - conceptspace pages. Found 2026-08-19. diff --git a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx index 2a4eacc0..13702fc7 100644 --- a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx +++ b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Box, Typography, @@ -38,7 +38,14 @@ export function BrowseStatementsPage() { const machinery = useMachinery() + // Bumped on every load so a late async write can tell it has been superseded + // by a newer sort change. + const loadTokenRef = useRef(0) + const loadStatements = useCallback(async (sort: SortOption) => { + const loadToken = loadTokenRef.current + 1 + loadTokenRef.current = loadToken + try { setLoading(true) setError(null) @@ -46,9 +53,12 @@ export function BrowseStatementsPage() { const orderBy = sort === 'mostSupporters' ? 'believerCount' : 'createdAt' const statements = await browseStatements(machinery, { limit: 50, orderBy }) + if (loadToken !== loadTokenRef.current) return + setStatements(statements) setLoading(false) } catch (err) { + if (loadToken !== loadTokenRef.current) return console.error('Error loading statements:', err) setError(err instanceof Error ? err.message : 'Failed to load statements') setLoading(false) diff --git a/ui/src/conceptspace/pages/StatementPage.tsx b/ui/src/conceptspace/pages/StatementPage.tsx index 509b4a02..e2d94146 100644 --- a/ui/src/conceptspace/pages/StatementPage.tsx +++ b/ui/src/conceptspace/pages/StatementPage.tsx @@ -33,7 +33,8 @@ export function StatementPage() { const [contentStatus, setContentStatus] = useState('unavailable') const [referencedDocuments, setReferencedDocuments] = useState>({}) - // Bumped on every load so a late operand read can tell it has been superseded. + // Bumped on every load so a late async write can tell it has been superseded + // by a newer navigation (e.g. the user moved to a different statement). const loadTokenRef = useRef(0) const machinery = useMachinery() @@ -61,6 +62,8 @@ export function StatementPage() { trustedAttesters, }) + if (loadToken !== loadTokenRef.current) return + if (!result) { setError('Statement not found') setLoading(false) @@ -104,11 +107,13 @@ export function StatementPage() { if (address) { const belief = await getUserBelief(machinery, address, statementCid) + if (loadToken !== loadTokenRef.current) return setUserBeliefState(belief?.beliefState ?? 0) } setLoading(false) } catch (err) { + if (loadToken !== loadTokenRef.current) return console.error('Error loading statement:', err) setError(err instanceof Error ? err.message : 'Failed to load statement') setLoading(false) diff --git a/ui/src/conceptspace/pages/UserProfilePage.tsx b/ui/src/conceptspace/pages/UserProfilePage.tsx index 53eca374..17e289fa 100644 --- a/ui/src/conceptspace/pages/UserProfilePage.tsx +++ b/ui/src/conceptspace/pages/UserProfilePage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Box, Typography, @@ -62,12 +62,19 @@ export function UserProfilePage() { const machinery = useMachinery() const trustedAttesters = useTrustedAttesters() + // Bumped on every load so a late async write can tell it has been superseded + // by a newer navigation (e.g. the user moved to a different profile). + const loadTokenRef = useRef(0) + const loadUserData = useCallback(async () => { if (!displayAddress) { setLoading(false) return } + const loadToken = loadTokenRef.current + 1 + loadTokenRef.current = loadToken + try { setLoading(true) setError(null) @@ -80,11 +87,14 @@ export function UserProfilePage() { }), ]) + if (loadToken !== loadTokenRef.current) return + setBeliefs(userBeliefs) setDisbeliefs(userDisbeliefs) setIndirectSupport(userIndirectSupport) setLoading(false) } catch (err) { + if (loadToken !== loadTokenRef.current) return console.error('Error loading user data:', err) setError(err instanceof Error ? err.message : 'Failed to load user data') setLoading(false) From 4682db593b8135daf3a3d419c23f67737af60159 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Fri, 28 Aug 2026 11:30:09 -0400 Subject: [PATCH 3/6] Sweep leftover CauseStarter package glue after ui/src/causestarter/ fold Delete unused files that referenced the old causestarter/src/ directory: - vite.config.ts (dev/build now go through ui/ workspace) - index.html (referenced deleted src/main.tsx) - vitest.config.ts (referenced deleted src/test/setup.ts) - tsconfig.app.json (included non-existent src/ directory) Fix tsconfig files: - tsconfig.json: simplify to extend tsconfig.node.json - tsconfig.node.json: update includes to [playwright.config.ts, eslint.config.js, e2e] Update 19 stale causestarter/src path references to ui/src/causestarter across: - docs/founder/ (2 files, 4 refs) - inbox.md (3 refs) - cause-assist/ (2 refs) - services/bridge-creator/ (1 ref) - specs/ (4 files, 6 refs) - fake-data-generation/ (2 refs) - causestarter/README.md (1 ref) Remove completed TODO item. --- TODO.md | 5 - cause-assist/src/coherenceClaim.test.ts | 2 +- cause-assist/src/coherenceClaim.ts | 2 +- causestarter/README.md | 2 +- causestarter/index.html | 14 -- causestarter/tsconfig.app.json | 29 --- causestarter/tsconfig.json | 6 +- causestarter/tsconfig.node.json | 2 +- causestarter/vite.config.ts | 173 ------------------ causestarter/vitest.config.ts | 19 -- docs/founder/bridge-cluster-wording-help.md | 4 +- docs/founder/shaping-your-cause-statements.md | 4 +- fake-data-generation/seedCauseRoster.ts | 4 +- inbox.md | 6 +- .../bridge-creator/src/clusterFromTick.ts | 2 +- specs/product/bridge-building-for-founders.md | 2 +- specs/product/bridge-cluster-as-nudger.md | 6 +- .../tech/published-data-ipfs-cutover-plan.md | 2 +- specs/user-docs.md | 2 +- 19 files changed, 21 insertions(+), 265 deletions(-) delete mode 100644 causestarter/index.html delete mode 100644 causestarter/tsconfig.app.json delete mode 100644 causestarter/vite.config.ts delete mode 100644 causestarter/vitest.config.ts diff --git a/TODO.md b/TODO.md index 7bcdac32..a6452ad5 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,6 @@ When an item from this page is done and no longer needs an LLM implementor's att - **(Ask)** Statement-generation exercise 2: abortion cutoff triple is in [`fake-data-generation/statement-generation-exercises/02-compromise-abortion.json`](fake-data-generation/statement-generation-exercises/02-compromise-abortion.json); modified-right was thickened after the attester refused the old text. Next: confirm attester blesses both modifieds, run `/critique-triple`, then Adam accept/reject before `seed-content/`. -- **(Tell)** After folding CauseStarter into `ui/src/causestarter/`, leftover package - glue still talks as if `causestarter/src` is the SPA: `causestarter/vite.config.ts` - is unused, Compose/Docker docs mix `:8090` and `:5174`, and some verifier prompts - may still cite deleted paths. Sweep when touching local-dev docs. - - Add a fresh-stack integration test for the alignment-trust bootstrap: publish an alignment vouch from a previously unknown wallet, observe the service's `TrustSet(..., 100)`, confirm a wallet with no personal graph sees that vouch diff --git a/cause-assist/src/coherenceClaim.test.ts b/cause-assist/src/coherenceClaim.test.ts index e803c50b..b62cf167 100644 --- a/cause-assist/src/coherenceClaim.test.ts +++ b/cause-assist/src/coherenceClaim.test.ts @@ -4,7 +4,7 @@ import { ROSTER_COHERENCE_CLAIM, ROSTER_COHERENCE_TOPIC } from './coherenceClaim describe('coherenceClaim well-known CIDs', () => { it('matches causestarter pinned roster coherence topic and claim', () => { - // Keep in lockstep with causestarter/src/lib/causeRoster.test.ts + // Keep in lockstep with ui/src/causestarter/lib/causeRoster.test.ts assert.equal( ROSTER_COHERENCE_TOPIC, 'bafkreigcuduguak3tvfltu56ggksxheukrqtbvf22zntpb7uibbpni27zm', diff --git a/cause-assist/src/coherenceClaim.ts b/cause-assist/src/coherenceClaim.ts index cf1d6ab4..6093aa5d 100644 --- a/cause-assist/src/coherenceClaim.ts +++ b/cause-assist/src/coherenceClaim.ts @@ -2,7 +2,7 @@ * Well-known topic/claim CIDs for roster coherence badges. * * Must stay pinned to the same PublishedData CIDs as - * causestarter/src/lib/causeRoster.ts (ROSTER_COHERENCE_TOPIC / CLAIM). + * ui/src/causestarter/lib/causeRoster.ts (ROSTER_COHERENCE_TOPIC / CLAIM). * Subject on chain is the roster document CID digest; claim/topic are these. */ import type { IpfsCidV1 } from '@commonality/sdk/utils' diff --git a/causestarter/README.md b/causestarter/README.md index 51ae0754..acf9d2ec 100644 --- a/causestarter/README.md +++ b/causestarter/README.md @@ -261,7 +261,7 @@ See [`cause-assist/README.md`](../cause-assist/README.md). Bridge-cluster wordin - **Alignment is per statement.** The fundable-projects dashboard is inlined on the statement page (`/statement/:cid`) and, as a union of planks, on the cause page. `/statement/:cid/board` redirects to the statement. -- **Cause store** (`src/lib/causeStore.ts`) keeps planks in `localStorage` so +- **Cause store** (`ui/src/causestarter/lib/causeStore.ts`) keeps planks in `localStorage` so unpublished wording survives reloads. - On-chain actions reuse the same SDK functions the main UI uses (`createAndSignStatement`, `browseStatements`, `believeStatement`, …). diff --git a/causestarter/index.html b/causestarter/index.html deleted file mode 100644 index cd1d754d..00000000 --- a/causestarter/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - CauseStarter - - -
- - - diff --git a/causestarter/tsconfig.app.json b/causestarter/tsconfig.app.json deleted file mode 100644 index 1a758fcd..00000000 --- a/causestarter/tsconfig.app.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "ESNext", - "types": ["vite/client"], - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "baseUrl": ".", - "paths": { - "@ui/*": ["../ui/src/*"] - } - }, - "include": ["src"], - "exclude": ["src/**/*.test.tsx", "src/**/*.test.ts", "src/test"] -} diff --git a/causestarter/tsconfig.json b/causestarter/tsconfig.json index 1ffef600..f2d9afee 100644 --- a/causestarter/tsconfig.json +++ b/causestarter/tsconfig.json @@ -1,7 +1,3 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "extends": "./tsconfig.node.json" } diff --git a/causestarter/tsconfig.node.json b/causestarter/tsconfig.node.json index f27e5aa6..1c74174e 100644 --- a/causestarter/tsconfig.node.json +++ b/causestarter/tsconfig.node.json @@ -18,5 +18,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts", "vitest.config.ts", "eslint.config.js"] + "include": ["playwright.config.ts", "eslint.config.js", "e2e"] } diff --git a/causestarter/vite.config.ts b/causestarter/vite.config.ts deleted file mode 100644 index 9c22508d..00000000 --- a/causestarter/vite.config.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import path from 'node:path' -import { defineConfig, loadEnv, type Plugin } from 'vite' -import react from '@vitejs/plugin-react' -import { endUserDocsPlugin } from '../ui/endUserDocsPlugin.ts' - -const indexerUrl = process.env.INDEXER_URL ?? 'http://localhost:42069' - -export default defineConfig(({ mode }) => { - const env = stripUndefinedValues({ ...loadEnv(mode, process.cwd(), ''), ...process.env }) - - return { - base: mode === 'ipfs' ? './' : '/', - build: { - outDir: 'dist', - }, - plugins: [ - react(), - runtimeConfigPlugin(env), - endUserDocsPlugin({ domain: 'causestarter' }), - ], - resolve: { - preserveSymlinks: true, - // Single React/MUI/wagmi graph when bundling ui feature modules into CauseStarter. - dedupe: [ - 'react', - 'react-dom', - 'react-router-dom', - '@mui/material', - '@mui/icons-material', - '@emotion/react', - '@emotion/styled', - 'wagmi', - 'viem', - '@tanstack/react-query', - ], - alias: { - ...sdkSourceAliases(), - '@ui': path.resolve(process.cwd(), '../ui/src'), - events: 'events', - }, - }, - optimizeDeps: { - exclude: sdkSubpathSpecifiers(), - esbuildOptions: { - define: { - global: 'globalThis', - }, - }, - }, - worker: { - format: 'es', - }, - server: { - port: 5174, - fs: { - allow: ['..'], - }, - proxy: { - '/conceptspace': indexerUrl, - '/status': indexerUrl, - '/api/cause-assist': { - target: process.env.CAUSE_ASSIST_URL ?? 'http://localhost:3002', - changeOrigin: true, - rewrite: (path: string) => path.replace(/^\/api\/cause-assist/, ''), - }, - '/api/implication-attester': { - target: process.env.IMPLICATION_ATTESTER_URL ?? 'http://localhost:3006/implication-attester', - changeOrigin: true, - rewrite: (path: string) => path.replace(/^\/api\/implication-attester/, ''), - }, - '/api/platform-api': 'http://localhost:3001', - '/api': indexerUrl, - }, - }, - } -}) - -const SDK_SOURCE_ENTRIES: Record = { - machinery: 'machinery.ts', - 'indexer-sync': 'indexer-sync.ts', - abis: 'abis.ts', - utils: 'utils/index.ts', - ...Object.fromEntries( - [ - 'conceptspace', - 'content-funding', - 'delegation', - 'displayable-documents', - 'fundingportals', - 'identity', - 'lazy-giving', - 'mutable-refs', - 'nudger-publications', - 'published-data', - 'signer-profiles', - 'subjectiv', - ].map((name) => [name, `subsystems/${name}/index.ts`]), - ), -} - -function sdkSubpathSpecifiers(): string[] { - return Object.keys(SDK_SOURCE_ENTRIES).map((name) => `@commonality/sdk/${name}`) -} - -function sdkSourceAliases(): Record { - const src = (p: string) => path.resolve(process.cwd(), '../sdk/src', p) - return Object.fromEntries( - Object.entries(SDK_SOURCE_ENTRIES).map(([name, file]) => [`@commonality/sdk/${name}`, src(file)]), - ) -} - -function runtimeConfigPlugin(env: Record): Plugin { - return { - name: 'causestarter-runtime-config', - closeBundle() { - const outDir = path.resolve(process.cwd(), 'dist') - mkdirSync(outDir, { recursive: true }) - writeFileSync(path.join(outDir, 'config.json'), `${JSON.stringify(buildRuntimeConfig(env), null, 2)}\n`) - }, - } -} - -function stripUndefinedValues(env: Record): Record { - return Object.fromEntries( - Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ) -} - -function buildRuntimeConfig(env: Record) { - const keys = [ - 'VITE_EVENT_CACHE_URL', - 'VITE_IPFS_GATEWAY', - 'COMMONALITY_ENVIRONMENT', - 'VITE_PLATFORM_API_URL', - 'VITE_CAUSE_ASSIST_URL', - 'VITE_IMPLICATION_ATTESTER_URL', - 'VITE_MAINNET_RPC_URL', - 'VITE_ETH_RPC_URL', - 'VITE_BELIEFS_CONTRACT_ADDRESS', - 'VITE_IMPLICATIONS_CONTRACT_ADDRESS', - 'VITE_ASSURANCE_CONTRACT_FACTORY_ADDRESS', - 'VITE_ERC1155_FACTORY_ADDRESS', - 'VITE_DELEGATABLE_NOTES_CONTRACT_ADDRESS', - 'VITE_RECURRING_PLEDGES_CONTRACT_ADDRESS', - 'VITE_NOTE_INTENT_CONTRACT_ADDRESS', - 'VITE_ALIGNMENT_ATTESTATIONS_CONTRACT_ADDRESS', - 'VITE_MUTABLE_REF_UPDATER_CONTRACT_ADDRESS', - 'VITE_TRUST_REGISTRY_CONTRACT_ADDRESS', - 'VITE_DEFAULT_ALIGNMENT_TRUST_ROOT', - 'VITE_NUDGE_PUBLICATIONS_CONTRACT_ADDRESS', - 'VITE_DEFAULT_NUDGERS', - 'VITE_PUBLISHED_DATA_CONTRACT_ADDRESS', - 'VITE_CONTENT_REGISTRY_ADDRESS', - 'VITE_CHANNEL_REGISTRY_ADDRESS', - 'VITE_CHANNEL_ESCROW_ADDRESS', - 'VITE_CREATOR_CONTRACT_FACTORY_ADDRESS', - 'VITE_PROJECT_FACTORY_CONTRACT_ADDRESS', - 'VITE_PAYMENT_TOKEN_ADDRESS', - 'VITE_CHAIN_ID', - 'VITE_PAYMENT_TOKEN_SYMBOL', - 'VITE_PAYMENT_TOKEN_DECIMALS', - 'VITE_COMMONALITY_URL', - 'VITE_LAZYGIVING_URL', - 'VITE_ALIGNMENT_URL', - 'VITE_TALLY_URL', - 'VITE_CONTENT_FUNDING_URL', - 'VITE_CIVILITY_URL', - 'VITE_COMMON_SENSE_MAJORITY_URL', - 'VITE_CONCEPTSPACE_URL', - ] - return Object.fromEntries(keys.flatMap((key) => (env[key] ? [[key, env[key]]] : []))) -} diff --git a/causestarter/vitest.config.ts b/causestarter/vitest.config.ts deleted file mode 100644 index d2250ea2..00000000 --- a/causestarter/vitest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path' -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' -import { endUserDocsPlugin } from '../ui/endUserDocsPlugin.ts' - -export default defineConfig({ - plugins: [react(), endUserDocsPlugin({ domain: 'causestarter' })], - resolve: { - alias: { - '@ui': path.resolve(__dirname, '../ui/src'), - '@commonality/sdk/published-data': path.resolve(__dirname, '../sdk/src/subsystems/published-data/index.ts'), - }, - }, - test: { - environment: 'jsdom', - setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], - }, -}) diff --git a/docs/founder/bridge-cluster-wording-help.md b/docs/founder/bridge-cluster-wording-help.md index 68486877..f52988d4 100644 --- a/docs/founder/bridge-cluster-wording-help.md +++ b/docs/founder/bridge-cluster-wording-help.md @@ -45,7 +45,7 @@ Two assistance layers. The **draft is the conversation memory**. Each turn is - the Christian / secular family-formation triple labeled as a **format example only** (civic conclusion only; do not copy a “we come from different places” closer) - a required return schema: `commonality.bridge-cluster-patch.v1` -They paste into Claude / ChatGPT / Grok, paste JSON back, **Apply pasted patch**, then review. We never see the chat. Code: `causestarter/src/lib/bridgeAssistBrief.ts`. +They paste into Claude / ChatGPT / Grok, paste JSON back, **Apply pasted patch**, then review. We never see the chat. Code: `ui/src/causestarter/lib/bridgeAssistBrief.ts`. ### 2. Hosted one-shot verbs (same class as plank sharpening) @@ -58,7 +58,7 @@ cause-assist endpoints — proposals, never auto-applied, never a standing strat | `POST /draft-bridge-plank` | One shared plank from ≥2 sides (modified wording, or stand-in planks when modified is skipped); strip justifications and coalition captions | | `POST /critique-triple` | Objections (`routing:`, `shape:`) and justification-leak warnings only — no rewrite. Optional parent texts. | -UI: `causestarter/src/components/BridgeClusterAssist.tsx`. Implementation: `cause-assist/src/bridgeClusterAssist.ts`. +UI: `ui/src/causestarter/components/BridgeClusterAssist.tsx`. Implementation: `cause-assist/src/bridgeClusterAssist.ts`. A later **BYOK in-page chat** (their key, our system prompt, we hold no transcript) is an escape hatch if founders demand it. It is not v1. diff --git a/docs/founder/shaping-your-cause-statements.md b/docs/founder/shaping-your-cause-statements.md index 41e83f8e..d04b7967 100644 --- a/docs/founder/shaping-your-cause-statements.md +++ b/docs/founder/shaping-your-cause-statements.md @@ -14,7 +14,7 @@ what remains open is one bug, at the end. - **Planks** are the cause. `CauseDraft` is a list of `CausePlank`s, each published separately and each carrying its own CID - (`causestarter/src/lib/causeStore.ts`). There is no main statement, no goal + (`ui/src/causestarter/lib/causeStore.ts`). There is no main statement, no goal field, and no launch step — a cause is "live" once any plank is on chain. - **Views** are real. `getStatementBelieverSets` returns the deduped believer/indirect/disbeliever ID sets per plank, and `computeViewCounts` folds @@ -476,7 +476,7 @@ domain-separation tag, a format version. Publishing through `PublishedData` make the bytes the bytes, and brings author attribution via `(publisher, cid)`, retraction semantics, and CID-first reads along with it. It is also what [ADR 0004](/specs/decisions/0004-user-publishes-displayable-data.md) already requires -for founder-authored content. `causestarter/src/lib/publishPlank.ts` does the +for founder-authored content. `ui/src/causestarter/lib/publishPlank.ts` does the same move for plank text. **Stable ID — a mutable ref.** [`MutableRefUpdater`](/specs/tech/subsystems/mutable-refs/README.md) diff --git a/fake-data-generation/seedCauseRoster.ts b/fake-data-generation/seedCauseRoster.ts index 54b4e6e3..68030d54 100644 --- a/fake-data-generation/seedCauseRoster.ts +++ b/fake-data-generation/seedCauseRoster.ts @@ -4,8 +4,8 @@ * content contract). * * The roster document extras must stay isomorphic with - * `causestarter/src/lib/causeRoster.ts` (`kind: causestarter.roster`, version 1). - * Bookmarks use the same JSON as `causestarter/src/lib/causeBookmarks.ts`. + * `ui/src/causestarter/lib/causeRoster.ts` (`kind: causestarter.roster`, version 1). + * Bookmarks use the same JSON as `ui/src/causestarter/lib/causeBookmarks.ts`. */ import { PublishedDataAbi, MutableRefUpdaterAbi } from '@commonality/sdk/abis'; diff --git a/inbox.md b/inbox.md index ef3f81c3..1b8c0e2b 100644 --- a/inbox.md +++ b/inbox.md @@ -69,7 +69,7 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - Ultimately we want vertical founders to host their own vertical-specific services like mediators, but can we have a middle ground where we can run it for them on our infrastructure (modulo blocklist concerns) until/unless they decide to host it themselves? -- How to eliminate CauseStarter’s reliance on browser `localStorage` for cause drafts / founder progress (`causestarter/src/lib/causeStore.ts`). Today drafts are origin-scoped (so Vite `:5174` vs Docker `:8090` don’t share them) and vanish across devices/clears. Worth thinking through durable alternatives (on-chain draft, IPFS + pointer, account-linked backend, etc.) without re-centralizing or making launch heavier. +- How to eliminate CauseStarter’s reliance on browser `localStorage` for cause drafts / founder progress (`ui/src/causestarter/lib/causeStore.ts`). Today drafts are origin-scoped (so Vite `:5174` vs Docker `:8090` don’t share them) and vanish across devices/clears. Worth thinking through durable alternatives (on-chain draft, IPFS + pointer, account-linked backend, etc.) without re-centralizing or making launch heavier. - Now that have (or at least are close to having) a proper testnet setup, can we start creating an ecosystem of simulated fake users of various types? (We can use LLMs to run the ones that need more intelligence, though ideally they'll mostly be made of conventional code, to avoid burning too many LLM tokens.) - Cause founder: cares a lot about some cause, comes across CauseStarter, tries actually forking the repo and making a new cause, etc. @@ -96,9 +96,9 @@ Also, don't let any of the items get too long; usually there's a separate .md fi - It's time to switch over to GitHub Issues, now that Sam is creating some. -- **Indexer-side believer-set aggregate — the last unfixed CauseStarter scale ceiling.** A scalability pass over the CauseStarter UI turned up four per-plank query fan-outs; all four are now concurrency-capped, and believer sets are cached across mounts (`causestarter/src/lib/concurrency.ts`, `causestarter/src/lib/believerSetsCache.ts`). What's left can't be fixed in the UI: `getStatementBelieverSets` ships full anonymized-ID *sets* to the browser, so a plank with 100k believers downloads 100k IDs to render one number, and the SDK's `limit: 10000` per-fetch ceiling truncates *silently* into a plausible-looking wrong count. The remedy and its constraints are already worked out in [shaping-your-cause-statements.md § Scale: the fold is fine, the transport isn't](docs/founder/shaping-your-cause-statements.md#scale-the-fold-is-fine-the-transport-isnt) — including why band 1 must stay exact if sketches are ever used. Needs indexer + SDK work, not UI work. +- **Indexer-side believer-set aggregate — the last unfixed CauseStarter scale ceiling.** A scalability pass over the CauseStarter UI turned up four per-plank query fan-outs; all four are now concurrency-capped, and believer sets are cached across mounts (`ui/src/causestarter/lib/concurrency.ts`, `ui/src/causestarter/lib/believerSetsCache.ts`). What's left can't be fixed in the UI: `getStatementBelieverSets` ships full anonymized-ID *sets* to the browser, so a plank with 100k believers downloads 100k IDs to render one number, and the SDK's `limit: 10000` per-fetch ceiling truncates *silently* into a plausible-looking wrong count. The remedy and its constraints are already worked out in [shaping-your-cause-statements.md § Scale: the fold is fine, the transport isn't](docs/founder/shaping-your-cause-statements.md#scale-the-fold-is-fine-the-transport-isnt) — including why band 1 must stay exact if sketches are ever used. Needs indexer + SDK work, not UI work. -- **`StatementPicker` searches a top-100-by-popularity window.** `causestarter/src/components/StatementPicker.tsx` calls `browseStatements({ limit: 100, orderBy: 'believerCount' })` and ranks locally. As the corpus grows, the right statement to reuse increasingly falls outside that window, so the picker degrades in *suggestion quality* rather than in speed — silently, and in exactly the direction that pushes organizers to write duplicate planks instead of reusing existing ones. Wants server-side relevance ranking. +- **`StatementPicker` searches a top-100-by-popularity window.** `ui/src/causestarter/components/StatementPicker.tsx` calls `browseStatements({ limit: 100, orderBy: 'believerCount' })` and ranks locally. As the corpus grows, the right statement to reuse increasingly falls outside that window, so the picker degrades in *suggestion quality* rather than in speed — silently, and in exactly the direction that pushes organizers to write duplicate planks instead of reusing existing ones. Wants server-side relevance ranking. ## Before mainnet diff --git a/services/bridge-creator/src/clusterFromTick.ts b/services/bridge-creator/src/clusterFromTick.ts index e3557f9f..db5e978a 100644 --- a/services/bridge-creator/src/clusterFromTick.ts +++ b/services/bridge-creator/src/clusterFromTick.ts @@ -1,6 +1,6 @@ /** * Lift this tick's statement triples into a CauseStarter-compatible bridge cluster - * when the mediator named parent causes. Same extras kinds as causestarter/src/lib/bridgeCluster.ts + * when the mediator named parent causes. Same extras kinds as ui/src/causestarter/lib/bridgeCluster.ts * and causeRoster.ts so /bridge/:owner/:slug can load them. */ diff --git a/specs/product/bridge-building-for-founders.md b/specs/product/bridge-building-for-founders.md index 92b0dcec..abcf518a 100644 --- a/specs/product/bridge-building-for-founders.md +++ b/specs/product/bridge-building-for-founders.md @@ -124,7 +124,7 @@ Two components, parameterized by nudger address + service URL rather than by CSM - **Mediator opt-in block** — generalize `csmMediatorNudger.ts` to take name, description, and address from cause config, and produce the existing `?addNudger=…` deep link. -In CauseStarter this becomes an entry in `SUPPORTING_TOOLS` (`causestarter/src/lib/tools.ts`) +In CauseStarter this becomes an entry in `SUPPORTING_TOOLS` (`ui/src/causestarter/lib/tools.ts`) plus a field on the cause record pointing at the founder's mediator address and service URL. ### Tier 4 — The beat-agent dependency diff --git a/specs/product/bridge-cluster-as-nudger.md b/specs/product/bridge-cluster-as-nudger.md index f329f547..7142bf3a 100644 --- a/specs/product/bridge-cluster-as-nudger.md +++ b/specs/product/bridge-cluster-as-nudger.md @@ -34,8 +34,8 @@ A human tick is **republish**. An LLM tick is the existing synthesizer schedule. ## What is already true in code -- 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. +- Cluster publish records `mediatorAddress` (the connected wallet). See `ui/src/causestarter/lib/bridgeCluster.ts`. +- `publishParentToModifiedNudges` (`ui/src/causestarter/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. @@ -51,7 +51,7 @@ When a slice is done, delete its bullet here (this spec’s list is the living b ### Slice 1 — Cluster opt-in (the original gap) -- [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] On `/bridge/:owner/:slug` (`ui/src/causestarter/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). diff --git a/specs/tech/published-data-ipfs-cutover-plan.md b/specs/tech/published-data-ipfs-cutover-plan.md index ebae1873..2a297a3e 100644 --- a/specs/tech/published-data-ipfs-cutover-plan.md +++ b/specs/tech/published-data-ipfs-cutover-plan.md @@ -14,7 +14,7 @@ The existing `published-data-ipfs-mirror` implements the intended write side: it | Flow | Path | Notes | | --- | --- | --- | -| Cause launch statements | `causestarter/src/pages/StartCausePage.tsx` | Requires `VITE_PUBLISHED_DATA_CONTRACT_ADDRESS` | +| Cause launch statements | `ui/src/causestarter/pages/StartCausePage.tsx` | Requires `VITE_PUBLISHED_DATA_CONTRACT_ADDRESS` | | Conceptspace create | `ui/src/conceptspace/components/CreateStatementForm.tsx` | Requires PublishedData (hard fail if missing) | | LazyGiving project/token metadata | `ui/src/lazy-giving/pages/CreateProjectPage.tsx` | Requires PublishedData; images are CID-only (no upload) | | Content-funding metadata | `ui/src/content-funding/pages/CreateContractPage.tsx` | Requires PublishedData | diff --git a/specs/user-docs.md b/specs/user-docs.md index 0ee4457c..0de745f0 100644 --- a/specs/user-docs.md +++ b/specs/user-docs.md @@ -29,6 +29,6 @@ Write the docs for humans (narrative, plain language). Then add a block to the d **User-facing docs live in:** - Role-based how-tos live on the site where the role is actually performed (e.g. `lazyGiving/get-your-project-funded.md`, `alignment/become-a-delegate.md`, `tally/express-what-you-care-about.md`). The cross-ecosystem index is in [docs/end-user/commonality/index.md](/docs/end-user/commonality/index.md) under "What can I do across the ecosystem?". Each role doc ends with an "On other sites" footer pointing at the cross-site connections. -- CauseStarter’s in-app docs (`causestarter/src` `/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/`. The everyday pitch is [the-jobs.md](/docs/end-user/causestarter/the-jobs.md). +- CauseStarter’s in-app docs (`ui/src/causestarter` `/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/`. The everyday pitch is [the-jobs.md](/docs/end-user/causestarter/the-jobs.md). - [docs/end-user/shared/use-case-walkthroughs/](/docs/end-user/shared/use-case-walkthroughs/README.md) — concrete scenarios - [docs/end-user/shared/key-ideas/](/docs/end-user/shared/key-ideas/README.md) — concept reference pages From 4e9a17a228b68a906c2ab0f5763fc34e89555cac Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 30 Aug 2026 16:47:18 -0400 Subject: [PATCH 4/6] Promote accepted abortion compromise triple --- TODO.md | 3 -- cause-assist/src/bridgeClusterAssist.test.ts | 12 ++++- cause-assist/src/bridgeClusterAssist.ts | 21 ++++++-- .../hidden-majority-patterns.md | 2 +- .../seed-content/compromise-abortion.json | 52 +++++++++++++++++++ .../02-compromise-abortion.json | 9 ++-- fake-data-generation/statement-generation.md | 2 +- .../test/seedMetadata.test.ts | 17 ++++++ specs/product/bridge-creator.md | 2 +- .../conceptspace/seed-content/README.md | 2 + .../seed-content/compromise-abortion.md | 27 ++++++++++ 11 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 fake-data-generation/seed-content/compromise-abortion.json create mode 100644 specs/tech/subsystems/conceptspace/seed-content/compromise-abortion.md diff --git a/TODO.md b/TODO.md index a6452ad5..e85e1573 100644 --- a/TODO.md +++ b/TODO.md @@ -22,8 +22,6 @@ When an item from this page is done and no longer needs an LLM implementor's att remains deferred per [belief-implication-board-inclusion-and-discovery.md](specs/product/belief-implication-board-inclusion-and-discovery.md). -- **(Ask)** Statement-generation exercise 2: abortion cutoff triple is in [`fake-data-generation/statement-generation-exercises/02-compromise-abortion.json`](fake-data-generation/statement-generation-exercises/02-compromise-abortion.json); modified-right was thickened after the attester refused the old text. Next: confirm attester blesses both modifieds, run `/critique-triple`, then Adam accept/reject before `seed-content/`. - - Add a fresh-stack integration test for the alignment-trust bootstrap: publish an alignment vouch from a previously unknown wallet, observe the service's `TrustSet(..., 100)`, confirm a wallet with no personal graph sees that vouch @@ -50,4 +48,3 @@ When an item from this page is done and no longer needs an LLM implementor's att - Verify the new local public-goods demo-seed storyline against a live stack. `PROJECT_SEED_METADATA[0]` is now "Riverside Community Garden" (aligned to `fundable-projects`/`local-community`/`local-food-systems`), `DETERMINISTIC_SEED_PROJECT_ALIGNMENT_COUNT` is 6 so no existing storyline lost its alignment, and `gen:seed:local` runs 12 users to keep the success-attester pool satisfied. Unit tests pass, but the seed has still never been run end-to-end: `stack.fresh-seeded` now passes (2026-08-03) but it seeds `tiny`, not `demo`. Run `./scripts/data.sh --wipe && ./scripts/data.sh --seed=demo` and confirm in the UI that the garden project shows an alignment vouch, contributions, and a success attestation. Consider also regenerating `data/seed-worker-outputs.json` if the Explorer fixture should mention the new cause. - Give the demo seed (`./scripts/data.sh --seed=demo`) more **local public-goods** coverage. One storyline now exists (see above), but rows A5 (federated regional) and E2 (nonprofit on the rails) in [use-cases.md](specs/product/use-cases.md) are still not demonstrable — and those are exactly the cases the strategy docs lean on hardest. Note also that the project-creation form ships "Community garden" / "Clean water" / "Learning circle" stock images that nothing in the seed uses. Found 2026-07-25 while verifying use-case statuses against the live UI. - diff --git a/cause-assist/src/bridgeClusterAssist.test.ts b/cause-assist/src/bridgeClusterAssist.test.ts index bb577dea..de2ed754 100644 --- a/cause-assist/src/bridgeClusterAssist.test.ts +++ b/cause-assist/src/bridgeClusterAssist.test.ts @@ -65,9 +65,19 @@ describe('bridge cluster wording verbs', () => { assert.match(request.systemPrompt, /Do not rewrite/) assert.match(request.systemPrompt, /routing:/) assert.match(request.systemPrompt, /shape:/) + assert.match(request.systemPrompt, /Containment is the intended modified → bridge relationship/) + assert.match(request.systemPrompt, /Never call a bridge "decorative"/) + assert.match(request.systemPrompt, /Semantic containment expressed in genuinely camp-specific prose is the desired shape/) + assert.match(request.systemPrompt, /Parent\/natural → modified is intentionally a nudge, not an implication/) + assert.match(request.systemPrompt, /Distilling that committed conclusion from the surrounding reasons is legitimate implication/) + assert.match(request.systemPrompt, /first-person willingness to accept a non-ideal policy/) + assert.match(request.systemPrompt, /arrays contain failures only/) assert.match(request.userPrompt, /parent_planks/) return { - objections: ['Shared plank requires a theological premise.'], + objections: [ + 'routing: This is the intended shape and is not an objection.', + 'Shared plank requires a theological premise.', + ], leakWarnings: ['God-talk leaked into the bridge plank.'], } as T }) diff --git a/cause-assist/src/bridgeClusterAssist.ts b/cause-assist/src/bridgeClusterAssist.ts index b355909a..f98a02a7 100644 --- a/cause-assist/src/bridgeClusterAssist.ts +++ b/cause-assist/src/bridgeClusterAssist.ts @@ -153,15 +153,23 @@ export const critiqueTripleStrategy: StatementStrategy< Also apply the implication-vs-nudge routing test. For each modified plank → bridge plank: if a reasonable signer of the modified would be annoyed at being asked to explicitly sign the bridge ("I already said that"), the pair should be an implication (containment). If they would not be annoyed, the modified does not contain the shared claim yet — object. If they would be annoyed but a different reasonable person would see a real extra claim in the bridge, do not treat that as containment; object that the pair is a nudge (or that the wording hides the delta), not an implication. Unreasonable annoyance is not a reason to bless an arrow. +Containment is the intended modified → bridge relationship, not an objection. Each modified is supposed to contain its camp's reasons plus the thinner shared conclusion, while the bridge states only that shared conclusion. Never call a bridge "decorative" or object merely because either or both modifieds semantically contain or restate it; that is a successful triple. Object only when containment is absent, or when apparent containment was manufactured by copying the same shared sentence nearly verbatim into both modifieds. Different camp-specific prose that commits to the same conclusion is not subset-by-concatenation. + +A thinner bridge conclusion will normally be embedded in longer, camp-specific modified prose. Distilling that committed conclusion from the surrounding reasons is legitimate implication, not "selective quotation" or a nudge. Apply signer annoyance to what the signer substantively committed to, not whether the bridge appeared as a standalone sentence. + +For compromise-in-the-middle bridges, first-person willingness to accept a non-ideal policy and a desire to settle the dispute are substantive shared beliefs and may be signable parts of the bridge. Do not confuse those with a coalition caption. Coalition captions comment on the camps or their differing reasons (for example, "we come from different places"), rather than stating what the signer accepts or wants. + +Parent/natural → modified is intentionally a nudge, not an implication: the modified must add the proposed compromise while reaffirming that camp's parent position. Do not object because a parent does not already contain the compromise, and do not apply the signer-annoyance test from a parent directly to the bridge. Object only if the parent itself already contains the compromise (making the modified layer decorative), or if the modified adds the compromise without preserving/reaffirming its parent's position. + Shape failures the attester will not catch (prefix with "shape:"): -- Identical or near-identical shared sentences pasted into both modifieds so subset fires (subset-by-concatenation). A bless is necessary, not sufficient. +- Identical or near-identical shared sentences pasted into both modifieds so subset fires (subset-by-concatenation). A bless is necessary, not sufficient. Semantic containment expressed in genuinely camp-specific prose is the desired shape and must not be flagged. - Shared plank still one camp's rant with the other camp's theology deleted, or a coalition caption ("we come from different places," commentary on whose reasons or maximalism). - Multi-register or too long to sign as a paragraph. - Parent/natural already contains the shared claim (triple decorative), or the modified introduces a civic program the parent never held without reaffirming the rest of the bundle (withhold-from-natural / belief jump). ${MEDIATION_RULES} -Return JSON only: {"objections":["..."],"leakWarnings":["..."]}. Empty arrays mean you found nothing load-bearing to flag. Prefix routing failures with "routing:" and shape failures with "shape:".`, +Return JSON only: {"objections":["..."],"leakWarnings":["..."]}. The arrays contain failures only, never successful-check commentary or affirmations. If an analysis concludes "this is intended," "not an objection," or "no failure found," omit it. Empty arrays mean you found nothing load-bearing to flag. Prefix routing failures with "routing:" and shape failures with "shape:".`, renderInput: (input) => ({ modified_planks: input.modifiedPlanks, bridge_plank: input.bridgePlank, @@ -169,8 +177,15 @@ Return JSON only: {"objections":["..."],"leakWarnings":["..."]}. Empty arrays me }), normalize: (value) => { const record = value && typeof value === 'object' ? value as Record : {} + const actualObjections = stringList(record.objections).filter((objection) => { + const normalized = objection.toLowerCase() + return !normalized.includes('not an objection') + && !normalized.includes('no routing failure') + && !normalized.includes('no shape failure') + && !normalized.includes('no failure found') + }) return { - objections: stringList(record.objections).slice(0, 12), + objections: actualObjections.slice(0, 12), leakWarnings: stringList(record.leakWarnings).slice(0, 8), } }, diff --git a/docs/end-user/common-sense-majority/hidden-majority-patterns.md b/docs/end-user/common-sense-majority/hidden-majority-patterns.md index 33bc8990..70bbffa2 100644 --- a/docs/end-user/common-sense-majority/hidden-majority-patterns.md +++ b/docs/end-user/common-sense-majority/hidden-majority-patterns.md @@ -134,7 +134,7 @@ For example: The mediator looks at those and sees that they don't actually conflict, or at least not too much; people who sign one of the above two statements might be willing to compromise on an abortion cutoff at 12-16 weeks. So it synthesizes: -- Modified moderate left: "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I don't mind forbidding abortions after maybe the first trimester or so — that would give women enough time to make a decision. I'd rather get this settled than keep fighting over it forever." +- Modified moderate left: "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I'd be okay with a law that gave women 12-16 weeks to decide and prohibited abortion afterward. I'd rather get this settled than keep fighting over it forever." - Modified moderate right: "Late-term abortion is horrific. I'd still rather not see abortions early in the pregnancy, but I don't feel as strongly about it. Allowing abortion during the first 12-16 weeks and forbidding it after that isn't what I'd write if I were making the law alone, but I'd be okay with that cutoff if it meant we got this settled instead of fighting over it forever." - Common ground: "I'd be okay with it if abortion were allowed during the first 12-16 weeks, and forbidden after that. This isn't my ideal outcome, but I'd rather get this settled than keep fighting over it forever." diff --git a/fake-data-generation/seed-content/compromise-abortion.json b/fake-data-generation/seed-content/compromise-abortion.json new file mode 100644 index 00000000..bb1868f9 --- /dev/null +++ b/fake-data-generation/seed-content/compromise-abortion.json @@ -0,0 +1,52 @@ +{ + "format": "commonality-seed-content-v1", + "id": "compromise-abortion", + "title": "Abortion — compromise in the middle", + "description": "Accepted left/right gestational-cutoff bridge triple. Copied from statement-generation-exercises/02-compromise-abortion.json after Adam accepted it on 2026-08-30.", + "notes": [ + "Curriculum step 3: a real-gap compromise-in-the-middle bridge. Process: fake-data-generation/statement-generation.md.", + "Naturals state each camp's concern without the deal. Modifieds reaffirm that concern and add the smallest settlement each camp can sign. Commonality contains only the shared settlement.", + "Live check on 2026-08-28 with deepseek/deepseek-v3.2: both designed-yes arrows blessed and all eight designed-no arrows refused with high confidence; /critique-triple returned no objections or leak warnings." + ], + "groups": [ + { + "id": "abortion-gestational-cutoff", + "title": "Abortion — first-trimester cutoff", + "notes": [ + "Gap: the moderate left's primary concern is that each woman has the option of aborting; the moderate right's primary concern is later-term abortions. Neither natural states a willingness to settle.", + "Modified → commonality is implication containment. Natural → modified remains a nudge because the cutoff and settlement are extra beliefs." + ], + "statements": [ + { + "id": "natural-left", + "role": "natural-left", + "text": "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want." + }, + { + "id": "natural-right", + "role": "natural-right", + "text": "Late-term abortion is horrific." + }, + { + "id": "modified-left", + "role": "modified-left", + "text": "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I'd be okay with a law that gave women 12-16 weeks to decide and prohibited abortion afterward. I'd rather get this settled than keep fighting over it forever." + }, + { + "id": "modified-right", + "role": "modified-right", + "text": "Late-term abortion is horrific. I'd still rather not see abortions early in the pregnancy, but I don't feel as strongly about it. Allowing abortion during the first 12-16 weeks and forbidding it after that isn't what I'd write if I were making the law alone, but I'd be okay with that cutoff if it meant we got this settled instead of fighting over it forever." + }, + { + "id": "commonality", + "role": "commonality", + "text": "I'd be okay with it if abortion were allowed during the first 12-16 weeks, and forbidden after that. This isn't my ideal outcome, but I'd rather get this settled than keep fighting over it forever." + } + ], + "implicationNotes": [ + "Expect yes: modified-left → commonality; modified-right → commonality.", + "Expect no: natural-left → commonality; natural-right → commonality; natural-left → modified-left; natural-right → modified-right; either modified → the other modified; commonality → either modified." + ] + } + ] +} diff --git a/fake-data-generation/statement-generation-exercises/02-compromise-abortion.json b/fake-data-generation/statement-generation-exercises/02-compromise-abortion.json index dd329986..5080873e 100644 --- a/fake-data-generation/statement-generation-exercises/02-compromise-abortion.json +++ b/fake-data-generation/statement-generation-exercises/02-compromise-abortion.json @@ -2,7 +2,7 @@ "format": "commonality-seed-content-v1", "id": "statement-generation-exercise-02", "title": "Exercise 2 — left/right compromise in the middle (abortion)", - "description": "One left/right gestational-cutoff triple using the canonical wording from docs/end-user/common-sense-majority/hidden-majority-patterns.md (mediator example). Not loaded by loadSeedCollections. Do not fork a second abortion text: if wording must change, change that page (and this copy) together.", + "description": "Gold copy of the accepted left/right gestational-cutoff triple using the canonical wording from docs/end-user/common-sense-majority/hidden-majority-patterns.md (mediator example). The live copy is seed-content/compromise-abortion.json; this exercises directory is not loaded by loadSeedCollections. Do not fork a second abortion text: if wording must change, change all three copies together.", "notes": [ "Curriculum step 3 in statement-generation.md (real-gap bridges, one pattern). Curriculum step 2 (easy in-camp implication) is not this file.", "Pattern: compromise in the middle. Overlap zone is a first-trimester / 12–16 week cutoff. Commonality is 'I'd be okay with it if…', not anyone's ideal.", @@ -33,7 +33,7 @@ { "id": "modified-left", "role": "modified-left", - "text": "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I don't mind forbidding abortions after maybe the first trimester or so — that would give women enough time to make a decision. I'd rather get this settled than keep fighting over it forever." + "text": "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I'd be okay with a law that gave women 12-16 weeks to decide and prohibited abortion afterward. I'd rather get this settled than keep fighting over it forever." }, { "id": "modified-right", @@ -53,9 +53,10 @@ ], "loopNotes": [ "Human: pick pattern (done: compromise-in-the-middle, abortion, reuse canonical). Veto if gap is 'same conclusion, different metaphysics' — it is not; this is an overlap-zone deal.", - "Shape risk: commonality names 12-16 weeks while modified-left says 'first trimester or so'. If the attester refuses left → CG for that grain, thicken the modified, do not paste the week range into both modifieds to buy subset-by-concatenation.", + "Resolved shape risk: commonality names 12-16 weeks, so modified-left now commits to that range in camp-specific prose. Do not paste the commonality sentence into both modifieds to buy subset-by-concatenation.", "2026-08-27 live attester (deepseek/deepseek-v3.2): modified-left → commonality yes/high; modified-right → commonality no/high (S2's 12–16 week cutoff not in S1). Thickened modified-right on hidden-majority-patterns.md (and this copy / bridge-creator.md) so the modified contains the cutoff without pasting the commonality paragraph.", - "Do not load into seed-content until Adam accepts after attester + critique-triple." + "2026-08-28 live check (deepseek/deepseek-v3.2): both designed-yes modified → commonality arrows bless and all eight designed-no arrows refuse with high confidence. /critique-triple returns no objections or leak warnings after its routing contract was clarified and pass commentary was excluded from objections. The production-default deepseek-v4-flash-0731 request stalled without output, so it was not treated as a judgment.", + "Adam accepted the triple on 2026-08-30; live copy: seed-content/compromise-abortion.json." ] } ] diff --git a/fake-data-generation/statement-generation.md b/fake-data-generation/statement-generation.md index b2f82da5..80442f67 100644 --- a/fake-data-generation/statement-generation.md +++ b/fake-data-generation/statement-generation.md @@ -162,5 +162,5 @@ failed the same checks. | # | Status | What | |---|---|---| | 1 | **In `seed-content/simple-causes.json`**. Gold set still in the exercises file. List not complete. Nested-place rollup is board inclusion (settled). | Simple causes: wants, earmark grain (kind + place). Ontario-wide planks are genuine wants, not implication parents. `npm run gen:seed:simple-causes-implications`. | -| 2 | Draft in [`statement-generation-exercises/02-compromise-abortion.json`](./statement-generation-exercises/02-compromise-abortion.json). Canonical texts live on hidden-majority-patterns.md. Live attester (2026-08-27, deepseek-v3.2): first modified-right refused; after thickening both modifieds bless, naturals refuse. `/critique-triple` not run. Not in seed-content. | One left/right abortion compromise-in-the-middle triple. Do not fork wording. | +| 2 | **In [`seed-content/compromise-abortion.json`](./seed-content/compromise-abortion.json)** after Adam accepted it on 2026-08-30. Gold copy remains in the exercises file; canonical wording remains on hidden-majority-patterns.md. Live check (2026-08-28, deepseek-v3.2): both designed-yes arrows bless and all eight designed-no arrows refuse with high confidence; `/critique-triple` returns no objections or leak warnings. Production-default v4-flash stalled rather than returning JSON. | One left/right abortion compromise-in-the-middle triple. Do not fork wording. | | 3 | Not started | Gate cause-assist suggestions on the same checks. | diff --git a/fake-data-generation/test/seedMetadata.test.ts b/fake-data-generation/test/seedMetadata.test.ts index 966973f1..45301837 100644 --- a/fake-data-generation/test/seedMetadata.test.ts +++ b/fake-data-generation/test/seedMetadata.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { CSM_MISSION_STATEMENT_CID, @@ -174,6 +175,22 @@ test('simple-causes seed collection copies the accepted exercise-1 plank texts', assert.equal(marketsOntario?.statement.role, 'unique'); }); +test('compromise-abortion seed collection copies the accepted exercise-2 statement texts', async () => { + const exercise = JSON.parse(await readFile( + new URL('../statement-generation-exercises/02-compromise-abortion.json', import.meta.url), + 'utf8', + )) as { groups: Array<{ statements: Array<{ id: string; role: string; text: string }> }> }; + const accepted = exercise.groups[0]?.statements ?? []; + const live = flattenSeedStatements(await loadSeedCollections()) + .filter((record) => record.collection.id === 'compromise-abortion') + .map((record) => record.statement); + + assert.deepEqual( + live.map(({ id, role, text }) => ({ id, role, text })), + accepted.map(({ id, role, text }) => ({ id, role, text })), + ); +}); + test('local-food-systems seed ref matches the mapping keys used by tiny seed injection', async () => { const records = flattenSeedStatements(await loadSeedCollections()); const plank = records.find((record) => diff --git a/specs/product/bridge-creator.md b/specs/product/bridge-creator.md index 13a84523..d45e89af 100644 --- a/specs/product/bridge-creator.md +++ b/specs/product/bridge-creator.md @@ -73,7 +73,7 @@ To make the kind of judgment the bridge-creator makes concrete: The bridge-creator has a common-ground anchor in its set: "I'd be okay with it if abortion were allowed during the first 12-16 weeks, and forbidden after that. I'd rather get this settled than keep fighting over it forever." It notices the above statements don't actually conflict with that anchor, so it synthesizes: -- Modified-left: "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I don't mind forbidding abortions after maybe the first trimester or so — that would give women enough time to make a decision. I'd rather get this settled than keep fighting over it forever." +- Modified-left: "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I'd be okay with a law that gave women 12-16 weeks to decide and prohibited abortion afterward. I'd rather get this settled than keep fighting over it forever." - Modified-right: "Late-term abortion is horrific. I'd still rather not see abortions early in the pregnancy, but I don't feel as strongly about it. Allowing abortion during the first 12-16 weeks and forbidding it after that isn't what I'd write if I were making the law alone, but I'd be okay with that cutoff if it meant we got this settled instead of fighting over it forever." - Common ground: the anchor itself. diff --git a/specs/tech/subsystems/conceptspace/seed-content/README.md b/specs/tech/subsystems/conceptspace/seed-content/README.md index a8203e1e..c552a30f 100644 --- a/specs/tech/subsystems/conceptspace/seed-content/README.md +++ b/specs/tech/subsystems/conceptspace/seed-content/README.md @@ -49,6 +49,8 @@ It may also help to have high-level statements like "I care about education" tha The showcase statements demonstrating the system's ability to find consensus (see [hidden-majority.md](./hidden-majority.md)). Each includes pole positions, moderate positions, and a commonality statement. +The accepted [abortion compromise-in-the-middle triple](./compromise-abortion.md) demonstrates the newer natural → modified nudge and modified → commonality implication shape with a specific 12–16 week settlement. + ### Cross-cutting meta-statements Statements about the system itself or political epistemology — the meta-statements most directly aligned with Commonality's thesis (see [meta.md](./meta.md)). diff --git a/specs/tech/subsystems/conceptspace/seed-content/compromise-abortion.md b/specs/tech/subsystems/conceptspace/seed-content/compromise-abortion.md new file mode 100644 index 00000000..7560b3ea --- /dev/null +++ b/specs/tech/subsystems/conceptspace/seed-content/compromise-abortion.md @@ -0,0 +1,27 @@ +# Abortion — compromise in the middle + +> Auto-generated from [`../../../../../fake-data-generation/seed-content/compromise-abortion.json`](../../../../../fake-data-generation/seed-content/compromise-abortion.json). Do not edit this file by hand; edit the JSON source instead. + +Accepted left/right gestational-cutoff bridge triple. Copied from statement-generation-exercises/02-compromise-abortion.json after Adam accepted it on 2026-08-30. + +Collection notes +- Curriculum step 3: a real-gap compromise-in-the-middle bridge. Process: fake-data-generation/statement-generation.md. +- Naturals state each camp's concern without the deal. Modifieds reaffirm that concern and add the smallest settlement each camp can sign. Commonality contains only the shared settlement. +- Live check on 2026-08-28 with deepseek/deepseek-v3.2: both designed-yes arrows blessed and all eight designed-no arrows refused with high confidence; /critique-triple returned no objections or leak warnings. + +--- + +## Abortion — first-trimester cutoff +Note: Gap: the moderate left's primary concern is that each woman has the option of aborting; the moderate right's primary concern is later-term abortions. Neither natural states a willingness to settle. +Note: Modified → commonality is implication containment. Natural → modified remains a nudge because the cutoff and settlement are extra beliefs. + +Statements +- **natural-left:** "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want." +- **natural-right:** "Late-term abortion is horrific." +- **modified-left:** "I want abortion to be available so that women aren't forced into going through with a pregnancy they don't want. I'd prefer abortion to be available throughout the whole pregnancy, but I'd be okay with a law that gave women 12-16 weeks to decide and prohibited abortion afterward. I'd rather get this settled than keep fighting over it forever." +- **modified-right:** "Late-term abortion is horrific. I'd still rather not see abortions early in the pregnancy, but I don't feel as strongly about it. Allowing abortion during the first 12-16 weeks and forbidding it after that isn't what I'd write if I were making the law alone, but I'd be okay with that cutoff if it meant we got this settled instead of fighting over it forever." +- **commonality:** "I'd be okay with it if abortion were allowed during the first 12-16 weeks, and forbidden after that. This isn't my ideal outcome, but I'd rather get this settled than keep fighting over it forever." + +Expected implication links +- Expect yes: modified-left → commonality; modified-right → commonality. +- Expect no: natural-left → commonality; natural-right → commonality; natural-left → modified-left; natural-right → modified-right; either modified → the other modified; commonality → either modified. From ca5ed86c0515b03aff7532a64c6adb5727fe7cf6 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 30 Aug 2026 19:53:10 -0400 Subject: [PATCH 5/6] Refactor reimbursement claims into nontransferable shares --- .../lazyGiving/retroactive-funding.md | 2 +- .../AssuranceContracts.sol | 154 +++++-- hardhat/test/AssuranceContracts.test.js | 38 +- hardhat/test/SecurityRegression.test.js | 5 +- indexer/abis/AssuranceContractAbi.ts | 397 +++++++++++++++++- sdk/abis/AssuranceContractAbi.ts | 397 +++++++++++++++++- sdk/src/subsystems/lazy-giving/folds.test.ts | 2 +- sdk/src/subsystems/lazy-giving/folds.ts | 58 ++- sdk/src/subsystems/lazy-giving/queries.ts | 1 + sdk/src/subsystems/lazy-giving/types.ts | 4 + ...checkpointed-reimbursement-claim-tokens.md | 54 +++ specs/decisions/README.md | 1 + .../legal/retroactive-funding-redesign.md | 2 +- specs/tech/subsystems/lazyGiving/README.md | 4 +- specs/tech/subsystems/lazyGiving/ui.md | 2 +- .../components/ReimbursementSection.tsx | 8 +- .../pages/ProjectDetailPage.test.tsx | 2 +- 17 files changed, 1057 insertions(+), 74 deletions(-) create mode 100644 specs/decisions/0013-checkpointed-reimbursement-claim-tokens.md diff --git a/docs/end-user/lazyGiving/retroactive-funding.md b/docs/end-user/lazyGiving/retroactive-funding.md index 1ab5d479..981de46a 100644 --- a/docs/end-user/lazyGiving/retroactive-funding.md +++ b/docs/end-user/lazyGiving/retroactive-funding.md @@ -17,7 +17,7 @@ Early contributors make uncertain work possible. Their contributions mint non-tr Later donations enter a reimbursement waterfall. Each donation becomes available to early contributors pro-rata, and no contributor can receive more than they originally put in. There is no interest, premium, bonus, or profit. The whole story is: **get your money back and fund the next one.** -An early contributor can also permanently forgo reimbursement. They keep their recognition receipt, but their contribution no longer counts as an outstanding claim. +An early contributor can also permanently forgo future reimbursement. They keep their recognition receipt and any reimbursement already earned, but burn the separate nontransferable claim representing what they could still receive later. ## Why this creates a useful cycle diff --git a/hardhat/contracts/individual-projects/AssuranceContracts.sol b/hardhat/contracts/individual-projects/AssuranceContracts.sol index f110d9bc..8901731e 100644 --- a/hardhat/contracts/individual-projects/AssuranceContracts.sol +++ b/hardhat/contracts/individual-projects/AssuranceContracts.sol @@ -3,7 +3,9 @@ pragma solidity 0.8.33; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {ContractMetadata} from "../utils/ContractMetadata.sol"; import {ERC1155PrimaryMarket} from "./ERC1155PrimaryMarket.sol"; import {AssuranceContract} from "./AssuranceContract.sol"; @@ -17,7 +19,7 @@ error InvalidERC1155Address(); error NoReimbursementAvailable(); error RetroactiveDonationExceedsOutstandingReimbursement(); error ForgoAmountExceedsAllowed(); -error ForgoWouldStrandWithdrawnReimbursement(); +error NonTransferableReimbursementClaim(); /** * @title MultiERC1155AssuranceContract @@ -32,7 +34,8 @@ contract MultiERC1155AssuranceContract is Ownable, ContractMetadata, AssuranceContract, - ERC1155PrimaryMarket + ERC1155PrimaryMarket, + ERC20 { using SafeERC20 for IERC20; @@ -44,12 +47,22 @@ contract MultiERC1155AssuranceContract is uint256 private _totalReceivedValue = 0; + // Legacy contribution-basis views retained for indexers and project totals. + // The live future claim is exposed by futureReimbursementClaims(). uint256 public totalEarlyContributions; uint256 public totalRetroReceived; uint256 public totalReimbursementsWithdrawn; mapping(address => uint256) public earlyContributions; mapping(address => uint256) public reimbursementsWithdrawn; + // Claims are represented as shares so a reimbursement can consume every + // holder's claim pro rata without iterating over all holders. Account state + // is checkpointed lazily when that account next interacts. + uint256 private constant REIMBURSEMENT_PER_SHARE_SCALE = 1e36; + uint256 public accumulatedReimbursementPerClaimShare; + mapping(address => uint256) private _reimbursementPerShareCheckpoint; + mapping(address => uint256) private _withdrawableReimbursements; + event RetroactiveDonationReceived(address indexed donor, uint256 amount); event ReimbursementWithdrawn(address indexed contributor, uint256 amount); event ReimbursementForgone(address indexed contributor, uint256 amount); @@ -68,7 +81,9 @@ contract MultiERC1155AssuranceContract is address _paymentToken, address _erc1155Addr, string memory projectMetadataCid - ) Ownable(owner) AssuranceContract(recipient, _paymentToken) { + ) Ownable(owner) + AssuranceContract(recipient, _paymentToken) + ERC20("Commonality Future Reimbursement Claim", "CFRC") { if (_erc1155Addr == address(0)) revert InvalidERC1155Address(); erc1155Addr = _erc1155Addr; // no reason to validate the CID, plus we can't really anyway @@ -140,9 +155,28 @@ contract MultiERC1155AssuranceContract is } function reimbursableAmount(address contributor) public view returns (uint256) { - if (totalEarlyContributions == 0) return 0; - uint256 earned = earlyContributions[contributor] * totalRetroReceived / totalEarlyContributions; - return earned - reimbursementsWithdrawn[contributor]; + return withdrawableReimbursements(contributor); + } + + /** @notice The caller's remaining, not-yet-earned at-cost claim. */ + function futureReimbursementClaims(address contributor) public view returns (uint256) { + uint256 totalShares = totalSupply(); + if (totalShares == 0) return 0; + return Math.mulDiv( + balanceOf(contributor), + outstandingReimbursementTotal(), + totalShares + ); + } + + /** @notice Reimbursement already earned by an account and available to withdraw. */ + function withdrawableReimbursements(address contributor) public view returns (uint256) { + uint256 newlyEarned = Math.mulDiv( + balanceOf(contributor), + accumulatedReimbursementPerClaimShare - _reimbursementPerShareCheckpoint[contributor], + REIMBURSEMENT_PER_SHARE_SCALE + ); + return _withdrawableReimbursements[contributor] + newlyEarned; } /** @@ -159,6 +193,14 @@ contract MultiERC1155AssuranceContract is function donateRetroactive(uint256 amount) external nonReentrant { requireAssuranceContractHasSucceeded(); if (amount > outstandingReimbursementTotal()) revert RetroactiveDonationExceedsOutstandingReimbursement(); + if (amount == 0 || totalSupply() == 0) { + revert RetroactiveDonationExceedsOutstandingReimbursement(); + } + accumulatedReimbursementPerClaimShare += Math.mulDiv( + amount, + REIMBURSEMENT_PER_SHARE_SCALE, + totalSupply() + ); totalRetroReceived += amount; IERC20(paymentToken).safeTransferFrom(msg.sender, address(this), amount); emit RetroactiveDonationReceived(msg.sender, amount); @@ -182,7 +224,11 @@ contract MultiERC1155AssuranceContract is } function _withdrawReimbursement(address recipientAddress, uint256 amount) internal { - if (amount == 0) revert NoReimbursementAvailable(); + _checkpointReimbursement(msg.sender); + if (amount == 0 || amount > _withdrawableReimbursements[msg.sender]) { + revert NoReimbursementAvailable(); + } + _withdrawableReimbursements[msg.sender] -= amount; reimbursementsWithdrawn[msg.sender] += amount; totalReimbursementsWithdrawn += amount; IERC20(paymentToken).safeTransfer(recipientAddress, amount); @@ -193,16 +239,11 @@ contract MultiERC1155AssuranceContract is * @notice Permanently give up part (or all) of your reimbursement claim, * turning that portion of your early contribution into a pure, * non-recoverable donation to the project. - * @dev The dual of {donateRetroactive}: that raises the numerator - * (`totalRetroReceived`), this lowers the denominator - * (`totalEarlyContributions`), so any retro money already received - * redistributes pro-rata to the remaining contributors. `amount` is - * capped at `outstandingReimbursementTotal()` (= T - R) so T can never - * drop below R — which simultaneously keeps that subtraction from - * underflowing and keeps every other contributor's reimbursement at or - * below cost. A contributor who has already withdrawn part of their - * reimbursement may only forgo down to the point where their remaining - * earned claim still covers what they withdrew. + * @dev Reimbursement already earned is checkpointed first and remains + * withdrawable by this contributor. Only claim shares representing the + * requested amount of future reimbursement are burned. The global + * contribution basis falls by the same amount, preserving the at-cost + * cap without reallocating previously earned money. * * This is the after-the-fact route. To contribute without ever taking * a claim in the first place, see {donateNormallyERC1155}, which @@ -252,28 +293,40 @@ contract MultiERC1155AssuranceContract is } function _forgoReimbursement(address contributorAddress, uint256 amount) internal { - uint256 contribution = earlyContributions[contributorAddress]; - if (amount == 0 || amount > contribution || amount > outstandingReimbursementTotal()) { + _checkpointReimbursement(contributorAddress); + uint256 claim = futureReimbursementClaims(contributorAddress); + if (amount == 0 || amount > claim) { revert ForgoAmountExceedsAllowed(); } - uint256 withdrawn = reimbursementsWithdrawn[contributorAddress]; - if (withdrawn > 0) { - // Post-forgo earned must still cover what was already withdrawn, or - // reimbursableAmount() would underflow for this contributor. - uint256 newContribution = contribution - amount; - uint256 newTotal = totalEarlyContributions - amount; // >= totalRetroReceived > 0 here - if (newContribution * totalRetroReceived / newTotal < withdrawn) { - revert ForgoWouldStrandWithdrawnReimbursement(); - } - } - - earlyContributions[contributorAddress] = contribution - amount; + uint256 holderShares = balanceOf(contributorAddress); + uint256 sharesToBurn = amount == claim + ? holderShares + : Math.mulDiv( + amount, + totalSupply(), + outstandingReimbursementTotal(), + Math.Rounding.Ceil + ); + if (sharesToBurn > holderShares) revert ForgoAmountExceedsAllowed(); + _burn(contributorAddress, sharesToBurn); + earlyContributions[contributorAddress] -= amount; totalEarlyContributions -= amount; emit ReimbursementForgone(contributorAddress, amount); } function recordPrimaryPurchase(address buyer, uint256 value) internal override { + _checkpointReimbursement(buyer); + uint256 outstanding = outstandingReimbursementTotal(); + uint256 newShares = totalSupply() == 0 || outstanding == 0 + ? value + : Math.mulDiv( + value, + totalSupply(), + outstanding, + Math.Rounding.Ceil + ); + _mint(buyer, newShares); earlyContributions[buyer] += value; totalEarlyContributions += value; } @@ -287,8 +340,43 @@ contract MultiERC1155AssuranceContract is // refunds require failure, donateRetroactive requires success.) uint256 tracked = earlyContributions[holder]; uint256 reduction = value < tracked ? value : tracked; - earlyContributions[holder] = tracked - reduction; - totalEarlyContributions -= reduction; + if (reduction > 0) _forgoReimbursement(holder, reduction); + } + + function _checkpointReimbursement(address contributor) internal { + uint256 checkpoint = _reimbursementPerShareCheckpoint[contributor]; + uint256 current = accumulatedReimbursementPerClaimShare; + if (current != checkpoint) { + _withdrawableReimbursements[contributor] += Math.mulDiv( + balanceOf(contributor), + current - checkpoint, + REIMBURSEMENT_PER_SHARE_SCALE + ); + _reimbursementPerShareCheckpoint[contributor] = current; + } + } + + /** @notice ERC-20 claim-share balance, named for reimbursement-domain callers. */ + function futureReimbursementClaimShares(address contributor) external view returns (uint256) { + return balanceOf(contributor); + } + + function totalFutureReimbursementClaimShares() external view returns (uint256) { + return totalSupply(); + } + + /** + * @dev Checkpointing is deliberately transfer-ready, but holder-to-holder + * movement remains prohibited by the accepted reimbursement-only legal + * posture. Removing the revert requires a separate legal/product decision. + */ + function _update(address from, address to, uint256 value) internal override { + if (from != address(0)) _checkpointReimbursement(from); + if (to != address(0) && to != from) _checkpointReimbursement(to); + if (from != address(0) && to != address(0)) { + revert NonTransferableReimbursementClaim(); + } + super._update(from, to, value); } function withdrawableRecipientBalance() internal view override returns (uint256) { diff --git a/hardhat/test/AssuranceContracts.test.js b/hardhat/test/AssuranceContracts.test.js index 09303def..a5fd3fe3 100644 --- a/hardhat/test/AssuranceContracts.test.js +++ b/hardhat/test/AssuranceContracts.test.js @@ -666,7 +666,7 @@ describe("MultiERC1155AssuranceContract", function () { expect(await assuranceContract.reimbursableAmount(bob.address)).to.equal(ethers.parseEther("6.0")); }); - it("redistributes already-received retro money pro-rata to the remaining contributors", async function () { + it("checkpoints earned reimbursement before burning the remaining future claim", async function () { await approveAndBuy(assuranceContract, alice, tokenAddr, [1], [4], ethers.parseEther("4.0")); await approveAndBuy(assuranceContract, bob, tokenAddr, [1], [6], ethers.parseEther("6.0")); @@ -674,12 +674,18 @@ describe("MultiERC1155AssuranceContract", function () { expect(await assuranceContract.reimbursableAmount(alice.address)).to.equal(ethers.parseEther("2.0")); expect(await assuranceContract.reimbursableAmount(bob.address)).to.equal(ethers.parseEther("3.0")); - // Alice forgoes her full contribution; the 5 already in redistributes to Bob. - await assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("4.0")); - expect(await assuranceContract.reimbursableAmount(alice.address)).to.equal(0); - expect(await assuranceContract.reimbursableAmount(bob.address)).to.equal(ethers.parseEther("5.0")); - // Bob is never reimbursed above his own cost of 6. - expect(await assuranceContract.outstandingReimbursementTotal()).to.equal(ethers.parseEther("1.0")); + // Alice has earned 2 and can burn only her remaining future claim of 2. + await assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("2.0")); + expect(await assuranceContract.reimbursableAmount(alice.address)).to.equal(ethers.parseEther("2.0")); + expect(await assuranceContract.reimbursableAmount(bob.address)).to.equal(ethers.parseEther("3.0")); + expect(await assuranceContract.futureReimbursementClaims(alice.address)).to.equal(0); + expect(await assuranceContract.futureReimbursementClaims(bob.address)).to.equal(ethers.parseEther("3.0")); + expect(await assuranceContract.outstandingReimbursementTotal()).to.equal(ethers.parseEther("3.0")); + + // Only Bob holds the future claim, while Alice keeps the 2 already earned. + await donateRetro(charlie, ethers.parseEther("3.0")); + expect(await assuranceContract.reimbursableAmount(alice.address)).to.equal(ethers.parseEther("2.0")); + expect(await assuranceContract.reimbursableAmount(bob.address)).to.equal(ethers.parseEther("6.0")); }); it("caps forgo at the outstanding reimbursement total (T - R)", async function () { @@ -689,8 +695,8 @@ describe("MultiERC1155AssuranceContract", function () { await donateRetro(charlie, ethers.parseEther("8.0")); // outstanding now 2 await expect(assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("3.0"))) .to.be.revertedWithCustomError(assuranceContract, "ForgoAmountExceedsAllowed"); - // Exactly the outstanding amount is allowed. - await expect(assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("2.0"))) + // Alice owns 40% of the remaining 2, so only 0.8 is hers to forgo. + await expect(assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("0.8"))) .to.emit(assuranceContract, "ReimbursementForgone"); }); @@ -704,14 +710,24 @@ describe("MultiERC1155AssuranceContract", function () { .to.be.revertedWithCustomError(assuranceContract, "ForgoAmountExceedsAllowed"); }); - it("won't let a contributor forgo below what they already withdrew", async function () { + it("lets a contributor withdraw earned reimbursement and separately burn a future claim", async function () { await approveAndBuy(assuranceContract, alice, tokenAddr, [1], [4], ethers.parseEther("4.0")); await approveAndBuy(assuranceContract, bob, tokenAddr, [1], [6], ethers.parseEther("6.0")); await donateRetro(charlie, ethers.parseEther("5.0")); await assuranceContract.connect(alice).withdrawReimbursement(); // withdraws 2, earned == withdrawn await expect(assuranceContract.connect(alice).forgoReimbursement(ethers.parseEther("1.0"))) - .to.be.revertedWithCustomError(assuranceContract, "ForgoWouldStrandWithdrawnReimbursement"); + .to.emit(assuranceContract, "ReimbursementForgone"); + expect(await assuranceContract.reimbursementsWithdrawn(alice.address)).to.equal(ethers.parseEther("2.0")); + expect(await assuranceContract.futureReimbursementClaims(alice.address)).to.equal(ethers.parseEther("1.0")); + }); + + it("keeps future reimbursement claim tokens nontransferable", async function () { + await approveAndBuy(assuranceContract, alice, tokenAddr, [1], [4], ethers.parseEther("4.0")); + + expect(await assuranceContract.balanceOf(alice.address)).to.equal(ethers.parseEther("4.0")); + await expect(assuranceContract.connect(alice).transfer(bob.address, 1n)) + .to.be.revertedWithCustomError(assuranceContract, "NonTransferableReimbursementClaim"); }); it("still refunds a forgoer if the project later fails (clamped, no underflow)", async function () { diff --git a/hardhat/test/SecurityRegression.test.js b/hardhat/test/SecurityRegression.test.js index 426e0169..870e8313 100644 --- a/hardhat/test/SecurityRegression.test.js +++ b/hardhat/test/SecurityRegression.test.js @@ -615,7 +615,8 @@ describe("Security Regression - Reentrancy Protection", function () { it("rejects a reentrant withdrawReimbursement from the mint callback", async function () { const honestClaim = await assuranceContract.reimbursableAmount(receiverAddress); - expect(honestClaim).to.equal(ethers.parseEther("0.1")); // 0.3 * 0.3 / 0.9 + // Per-share fixed-point accounting rounds down by at most one base unit here. + expect(honestClaim).to.equal(ethers.parseEther("0.1") - 1n); // 0.3 * 0.3 / 0.9 const balanceBefore = await paymentToken.balanceOf(receiverAddress); @@ -856,4 +857,4 @@ describe("Security Regression - Gas Griefing", function () { expect(receipt.status).to.equal(1); }); }); -}); \ No newline at end of file +}); diff --git a/indexer/abis/AssuranceContractAbi.ts b/indexer/abis/AssuranceContractAbi.ts index d9de2222..c03996ec 100644 --- a/indexer/abis/AssuranceContractAbi.ts +++ b/indexer/abis/AssuranceContractAbi.ts @@ -64,13 +64,94 @@ export const AssuranceContractAbi = [ "type": "error" }, { - "inputs": [], - "name": "ForgoAmountExceedsAllowed", + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", "type": "error" }, { "inputs": [], - "name": "ForgoWouldStrandWithdrawnReimbursement", + "name": "ForgoAmountExceedsAllowed", "type": "error" }, { @@ -88,6 +169,11 @@ export const AssuranceContractAbi = [ "name": "NoReimbursementAvailable", "type": "error" }, + { + "inputs": [], + "name": "NonTransferableReimbursementClaim", + "type": "error" + }, { "inputs": [], "name": "OnlyRecipientCanWithdraw", @@ -156,6 +242,31 @@ export const AssuranceContractAbi = [ "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -382,6 +493,111 @@ export const AssuranceContractAbi = [ "name": "RetroactiveDonationReceived", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "accumulatedReimbursementPerClaimShare", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -415,6 +631,19 @@ export const AssuranceContractAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -535,6 +764,44 @@ export const AssuranceContractAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "futureReimbursementClaimShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "futureReimbursementClaims", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "getAssuranceContractProgress", @@ -548,6 +815,19 @@ export const AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -793,6 +1073,19 @@ export const AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "totalEarlyContributions", @@ -806,6 +1099,19 @@ export const AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalFutureReimbursementClaimShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "totalReimbursementsWithdrawn", @@ -832,6 +1138,72 @@ export const AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -876,5 +1248,24 @@ export const AssuranceContractAbi = [ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "withdrawableReimbursements", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" } ] as const; diff --git a/sdk/abis/AssuranceContractAbi.ts b/sdk/abis/AssuranceContractAbi.ts index db6eb6ff..a3801391 100644 --- a/sdk/abis/AssuranceContractAbi.ts +++ b/sdk/abis/AssuranceContractAbi.ts @@ -64,13 +64,94 @@ export const MultiERC1155AssuranceContractAbi = [ "type": "error" }, { - "inputs": [], - "name": "ForgoAmountExceedsAllowed", + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", "type": "error" }, { "inputs": [], - "name": "ForgoWouldStrandWithdrawnReimbursement", + "name": "ForgoAmountExceedsAllowed", "type": "error" }, { @@ -88,6 +169,11 @@ export const MultiERC1155AssuranceContractAbi = [ "name": "NoReimbursementAvailable", "type": "error" }, + { + "inputs": [], + "name": "NonTransferableReimbursementClaim", + "type": "error" + }, { "inputs": [], "name": "OnlyRecipientCanWithdraw", @@ -156,6 +242,31 @@ export const MultiERC1155AssuranceContractAbi = [ "name": "ZeroAddress", "type": "error" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, { "anonymous": false, "inputs": [ @@ -382,6 +493,111 @@ export const MultiERC1155AssuranceContractAbi = [ "name": "RetroactiveDonationReceived", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "accumulatedReimbursementPerClaimShare", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -415,6 +631,19 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -535,6 +764,44 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "nonpayable", "type": "function" }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "futureReimbursementClaimShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "futureReimbursementClaims", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "getAssuranceContractProgress", @@ -548,6 +815,19 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -793,6 +1073,19 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "totalEarlyContributions", @@ -806,6 +1099,19 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalFutureReimbursementClaimShares", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [], "name": "totalReimbursementsWithdrawn", @@ -832,6 +1138,72 @@ export const MultiERC1155AssuranceContractAbi = [ "stateMutability": "view", "type": "function" }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, { "inputs": [ { @@ -876,5 +1248,24 @@ export const MultiERC1155AssuranceContractAbi = [ "outputs": [], "stateMutability": "nonpayable", "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "contributor", + "type": "address" + } + ], + "name": "withdrawableReimbursements", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" } ] as const; diff --git a/sdk/src/subsystems/lazy-giving/folds.test.ts b/sdk/src/subsystems/lazy-giving/folds.test.ts index 7e67340f..4aa75b39 100644 --- a/sdk/src/subsystems/lazy-giving/folds.test.ts +++ b/sdk/src/subsystems/lazy-giving/folds.test.ts @@ -568,6 +568,7 @@ describe('foldReimbursements', () => { contributor: PARTICIPANT_A, currency: ETH_CURRENCY, earlyContribution: '600', + futureReimbursementClaim: '300', reimbursableAmount: '100', withdrawnAmount: '200', forgoneAmount: '0', @@ -637,4 +638,3 @@ describe('foldProjectTokens', () => { assert.deepStrictEqual(events, copy); }); }); - diff --git a/sdk/src/subsystems/lazy-giving/folds.ts b/sdk/src/subsystems/lazy-giving/folds.ts index 01b602d9..ad4c0a55 100644 --- a/sdk/src/subsystems/lazy-giving/folds.ts +++ b/sdk/src/subsystems/lazy-giving/folds.ts @@ -254,9 +254,12 @@ export function foldReimbursements( fundingCurrency: Currency = ETH_CURRENCY, ): { project: ProjectReimbursementState; contributors: ContributorReimbursementState[] } { const contributions = new Map(); + const futureClaims = new Map(); + const withdrawable = new Map(); const withdrawn = new Map(); const forgone = new Map(); let totalRetroactiveDonations = 0n; + let outstanding = 0n; const add = (map: Map, address: string, amount: bigint) => { const key = address.toLowerCase(); @@ -267,17 +270,50 @@ export function foldReimbursements( const tracked = contributions.get(key) ?? 0n; contributions.set(key, tracked > amount ? tracked - amount : 0n); }; + const subtractClamped = (map: Map, address: string, amount: bigint) => { + const key = address.toLowerCase(); + const tracked = map.get(key) ?? 0n; + const reduction = tracked < amount ? tracked : amount; + map.set(key, tracked - reduction); + return reduction; + }; for (const { type, event } of events) { switch (type) { - case 'bought': add(contributions, event.participant, event.totalCost); break; + case 'bought': + add(contributions, event.participant, event.totalCost); + add(futureClaims, event.participant, event.totalCost); + outstanding += event.totalCost; + break; // Match recordPrimaryRefund: the reimbursement basis may already have // been reduced by a forgo, while the full token value is still refunded. - case 'sold': subtractContributionClamped(event.participant, event.totalCost); break; - case 'retroactiveDonation': totalRetroactiveDonations += event.amount; break; - case 'reimbursementWithdrawn': add(withdrawn, event.contributor, event.amount); break; + case 'sold': { + subtractContributionClamped(event.participant, event.totalCost); + const reduction = subtractClamped(futureClaims, event.participant, event.totalCost); + outstanding -= reduction; + break; + } + case 'retroactiveDonation': { + const before = outstanding; + if (before > 0n) { + for (const [contributor, claim] of futureClaims) { + const earned = claim * event.amount / before; + futureClaims.set(contributor, claim - earned); + add(withdrawable, contributor, earned); + } + } + outstanding -= event.amount; + totalRetroactiveDonations += event.amount; + break; + } + case 'reimbursementWithdrawn': + subtractClamped(withdrawable, event.contributor, event.amount); + add(withdrawn, event.contributor, event.amount); + break; case 'reimbursementForgone': add(contributions, event.contributor, -event.amount); + subtractClamped(futureClaims, event.contributor, event.amount); + outstanding -= event.amount; add(forgone, event.contributor, event.amount); break; } @@ -286,22 +322,20 @@ export function foldReimbursements( const totalEarlyContributions = [...contributions.values()].reduce((sum, value) => sum + value, 0n); const totalWithdrawn = [...withdrawn.values()].reduce((sum, value) => sum + value, 0n); const totalForgone = [...forgone.values()].reduce((sum, value) => sum + value, 0n); - const outstanding = totalEarlyContributions > totalRetroactiveDonations - ? totalEarlyContributions - totalRetroactiveDonations - : 0n; - const addresses = new Set([...contributions.keys(), ...withdrawn.keys(), ...forgone.keys()]); + const addresses = new Set([ + ...contributions.keys(), ...futureClaims.keys(), ...withdrawable.keys(), + ...withdrawn.keys(), ...forgone.keys(), + ]); const contributors = [...addresses].map((contributor) => { const contribution = contributions.get(contributor) ?? 0n; const contributorWithdrawn = withdrawn.get(contributor) ?? 0n; - const accrued = totalEarlyContributions === 0n - ? 0n - : contribution * totalRetroactiveDonations / totalEarlyContributions; - const reimbursable = accrued > contributorWithdrawn ? accrued - contributorWithdrawn : 0n; + const reimbursable = withdrawable.get(contributor) ?? 0n; return { projectAddress, contributor, currency: fundingCurrency, earlyContribution: contribution.toString(), + futureReimbursementClaim: (futureClaims.get(contributor) ?? 0n).toString(), reimbursableAmount: reimbursable.toString(), withdrawnAmount: contributorWithdrawn.toString(), forgoneAmount: (forgone.get(contributor) ?? 0n).toString(), diff --git a/sdk/src/subsystems/lazy-giving/queries.ts b/sdk/src/subsystems/lazy-giving/queries.ts index 6764282d..3a6192ea 100644 --- a/sdk/src/subsystems/lazy-giving/queries.ts +++ b/sdk/src/subsystems/lazy-giving/queries.ts @@ -560,6 +560,7 @@ export async function getContributorReimbursementState( contributor: contributorAddress.toLowerCase(), currency: snapshot.project.currency, earlyContribution: '0', + futureReimbursementClaim: '0', reimbursableAmount: '0', withdrawnAmount: '0', forgoneAmount: '0', diff --git a/sdk/src/subsystems/lazy-giving/types.ts b/sdk/src/subsystems/lazy-giving/types.ts index 205627e3..4393659b 100644 --- a/sdk/src/subsystems/lazy-giving/types.ts +++ b/sdk/src/subsystems/lazy-giving/types.ts @@ -117,7 +117,11 @@ export interface ContributorReimbursementState { projectAddress: string; contributor: string; currency: Currency; + /** Historical at-cost contribution basis after any amounts forgone/refunded. */ earlyContribution: string; + /** Portion of the at-cost claim not yet converted into earned reimbursement. */ + futureReimbursementClaim: string; + /** Earned, unwithdrawn reimbursement. */ reimbursableAmount: string; withdrawnAmount: string; forgoneAmount: string; diff --git a/specs/decisions/0013-checkpointed-reimbursement-claim-tokens.md b/specs/decisions/0013-checkpointed-reimbursement-claim-tokens.md new file mode 100644 index 00000000..cdc5de7d --- /dev/null +++ b/specs/decisions/0013-checkpointed-reimbursement-claim-tokens.md @@ -0,0 +1,54 @@ +# 0013. Checkpointed reimbursement claim tokens + +- **Status:** Accepted +- **Date:** 2026-08-30 +- **Related specs:** [`specs/tech/subsystems/lazyGiving/README.md`](../tech/subsystems/lazyGiving/README.md), [`specs/product/legal/retroactive-funding-redesign.md`](../product/legal/retroactive-funding-redesign.md) + +## Context + +The reimbursement pool derived every contributor's earned and remaining amount from +one mutable contribution basis. Forgoing that basis after money arrived therefore +reallocated reimbursement the contributor had already earned. We wanted the clearer +rule that each later donation converts part of the current holder's future claim into +money permanently withdrawable by that holder, without making each donation iterate +over an unbounded contributor set. + +Recognition receipts cannot themselves represent this claim: ordinary donors receive +the same receipts while explicitly taking no reimbursement claim. + +## Decision + +Each project issues a separate ERC-20 reimbursement-claim share token to early funders. +The token is nontransferable. A global reimbursement-per-share accumulator lets later +donations consume future claims pro rata in O(1); accounts checkpoint lazily, and +accrued reimbursement remains with the holder who earned it. Forgoing burns only the +holder's remaining future claim. + +The transfer hook checkpoints both sides so the accounting has a coherent technical +seam if transferability is reconsidered, but holder-to-holder transfers revert. This +is a technical refactor, not a change to the reimbursement-only legal or product +posture accepted in [ADR 0003](./0003-reimbursement-only-retroactive-funding.md). + +## Alternatives considered + +- **Write every holder's mappings on each donation** — rejected because reimbursement + cost would grow with contributor count and could eventually exceed the block gas limit. +- **Keep deriving earned reimbursement from mutable contribution basis** — rejected + because a later forgo could reallocate reimbursement that had already been earned. +- **Make recognition receipts carry claims** — rejected because pure donors and scouts + can hold indistinguishable recognition receipts with different reimbursement rights. +- **Enable claim-token transfers now** — rejected because that would reverse ADR 0003's + legal-risk decision and materially change the product into a transferable-claim system. + +## Consequences + +Later donations remain O(1), withdrawals remain pull-based, and reimbursement cannot +exceed the at-cost claim. Claim balances decline economically as reimbursement accrues; +earned balances survive later claim burns. The separate ERC-20 adds contract surface +and fixed-point rounding that tests and clients must handle. + +Transferability, resale UI, markup, or a secondary market remain prohibited. The +checkpoint-ready hook is not authorization or an assertion of legality. Reconsidering +the revert requires Canadian and US securities counsel to review a concrete design, +followed by an explicit product decision and a new ADR that supersedes ADR 0003 where +necessary. diff --git a/specs/decisions/README.md b/specs/decisions/README.md index 9a96286c..3acc6ab9 100644 --- a/specs/decisions/README.md +++ b/specs/decisions/README.md @@ -60,3 +60,4 @@ instance most needs answered and can't get anywhere else. | [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 | +| [0013](./0013-checkpointed-reimbursement-claim-tokens.md) | Checkpointed reimbursement claim tokens | Accepted | diff --git a/specs/product/legal/retroactive-funding-redesign.md b/specs/product/legal/retroactive-funding-redesign.md index e551153c..246f7806 100644 --- a/specs/product/legal/retroactive-funding-redesign.md +++ b/specs/product/legal/retroactive-funding-redesign.md @@ -218,7 +218,7 @@ founder, and it drags every community UI into the regulated perimeter. ## Implementation status (Jul 2026) - **Done:** LazyGiving receipt transfers are disabled; its contribution actions no longer use the secondary market. -- **Done:** assurance contracts implement capped `donateRetroactive`, pull-based pro-rata withdrawal, normal-donation/waiver, and reimbursement-forgoing paths. Donations stop at the outstanding reimbursable amount rather than overflowing to the project. +- **Done:** assurance contracts implement capped `donateRetroactive`, pull-based pro-rata withdrawal, normal-donation/waiver, and reimbursement-forgoing paths. Nontransferable claim shares checkpoint later donations into reimbursement earned by the current holder; forgoing burns only the future claim. Donations stop at the outstanding reimbursable amount rather than overflowing to the project. This accounting refactor does not change the reimbursement-only legal posture; see [ADR 0013](../../decisions/0013-checkpointed-reimbursement-claim-tokens.md). - **Done:** SDK folds/actions and the LazyGiving UI expose outstanding reimbursement, withdrawal, forgoing, and “close the loop” donation flows. - **Done:** end-user LazyGiving and strategy documentation was rewritten around reimbursement and reputation. - **Residual:** generic secondary-market code and old resale language still exist elsewhere in the repository. They are not part of this LazyGiving flow, but stale specs must be corrected. *(Update 2026-08-05: the transferable content-item tokens named here were made non-transferable and the content-funding docs/specs/UI were scrubbed to match — see the rollout tracker's content-funding pass.)* diff --git a/specs/tech/subsystems/lazyGiving/README.md b/specs/tech/subsystems/lazyGiving/README.md index ffc6534e..df9307c3 100644 --- a/specs/tech/subsystems/lazyGiving/README.md +++ b/specs/tech/subsystems/lazyGiving/README.md @@ -13,7 +13,9 @@ Design decisions: ## Retroactive reimbursement -LazyGiving projects do not deploy or use a secondary marketplace. Receipt transfers are disabled. Successful projects accept later donations through a pull-based, pro-rata reimbursement pool capped by each early contributor's original contribution. The generic `ERC1155SecondaryMarket` contract remains in the repository for legacy or non-LazyGiving uses, but it is not part of this product flow. +LazyGiving projects do not deploy or use a secondary marketplace. Receipt transfers are disabled. Successful projects accept later donations through a pull-based, pro-rata reimbursement pool capped by each early contributor's original contribution. Scouts receive separate nontransferable ERC-20 future-reimbursement claim shares; recognition receipts do not carry the claim because normal donors receive the same receipts without reimbursement rights. Each later donation checkpoints value globally from future claims into reimbursement earned by the current claim holders. Forgoing burns only future claim, never reimbursement already earned. The global per-share accumulator keeps donations O(1); individual accounts settle lazily when they interact. The generic `ERC1155SecondaryMarket` contract remains in the repository for legacy or non-LazyGiving uses, but it is not part of this product flow. + +Claim-token transfers revert. Their transfer hook is checkpoint-aware only to preserve a technically coherent seam if counsel and a later product decision ever approve transferability; it does not change the reimbursement-only posture. See [ADR 0013](../../../decisions/0013-checkpointed-reimbursement-claim-tokens.md). ## SDK diff --git a/specs/tech/subsystems/lazyGiving/ui.md b/specs/tech/subsystems/lazyGiving/ui.md index d6bdf409..ce906442 100644 --- a/specs/tech/subsystems/lazyGiving/ui.md +++ b/specs/tech/subsystems/lazyGiving/ui.md @@ -62,7 +62,7 @@ Table of contributors sorted by net contribution (totalContributed - totalRefund Columns: address, total contributed, reimbursement preference, amount reimbursed, and amount outstanding. ### Forgo reimbursement -An early contributor may permanently forgo any remaining reimbursement claim while retaining the non-transferable recognition receipt. This is the distinction between "Donate normally" and "Fund as a scout"; there is no token burn or investor conversion. +An early contributor may permanently forgo any remaining reimbursement claim while retaining the non-transferable recognition receipt. This burns the separate nontransferable future-claim shares, not the recognition receipt, and does not affect reimbursement already earned. This is the distinction between "Donate normally" and "Fund as a scout"; it is not an investor conversion. ### Reimbursement history Shows later donations into the reimbursement pool and early-contributor withdrawals. It does not show token trades because LazyGiving receipts are non-transferable. diff --git a/ui/src/lazy-giving/components/ReimbursementSection.tsx b/ui/src/lazy-giving/components/ReimbursementSection.tsx index 3c5778fb..ca34fedf 100644 --- a/ui/src/lazy-giving/components/ReimbursementSection.tsx +++ b/ui/src/lazy-giving/components/ReimbursementSection.tsx @@ -24,8 +24,8 @@ export function ReimbursementSection({ project, projectState, contributorState, const currency = projectState.currency const outstanding = BigInt(projectState.outstandingReimbursement) const reimbursable = BigInt(contributorState?.reimbursableAmount ?? '0') - const remainingClaim = BigInt(contributorState?.earlyContribution ?? '0') - const maxForgo = remainingClaim < outstanding ? remainingClaim : outstanding + const remainingClaim = BigInt(contributorState?.futureReimbursementClaim ?? '0') + const maxForgo = remainingClaim const contract: AssuranceContract = { address: project.id as `0x${string}`, abi: AssuranceContractAbi } const run = async (kind: 'donate' | 'withdraw' | 'forgo', action: () => Promise, message: string) => { @@ -77,13 +77,13 @@ export function ReimbursementSection({ project, projectState, contributorState, {contributorState && remainingClaim > 0n && ( Your scout reimbursement - Available now: {formatCurrencyAmount(reimbursable, currency)} · Remaining basis: {formatCurrencyAmount(remainingClaim, currency)} + Available now: {formatCurrencyAmount(reimbursable, currency)} · Future claim: {formatCurrencyAmount(remainingClaim, currency)} setForgoAmount(event.target.value)} size="small" disabled={maxForgo === 0n} /> - Forgoing is permanent and does not remove your recognition receipt. + Forgoing burns only your future claim. Reimbursement already earned and your recognition receipt are unchanged. )} {error && {error}} diff --git a/ui/src/lazy-giving/pages/ProjectDetailPage.test.tsx b/ui/src/lazy-giving/pages/ProjectDetailPage.test.tsx index 358faeb9..bc1c21d7 100644 --- a/ui/src/lazy-giving/pages/ProjectDetailPage.test.tsx +++ b/ui/src/lazy-giving/pages/ProjectDetailPage.test.tsx @@ -164,7 +164,7 @@ describe('ProjectDetailPage', () => { vi.mocked(getContributorReimbursementState).mockResolvedValue({ projectAddress: mockProjectAddress, contributor: mockProjectAddress, currency: { kind: 'native', symbol: 'ETH', decimals: 18, tokenAddress: null, tokenType: 0 }, - earlyContribution: '0', reimbursableAmount: '0', withdrawnAmount: '0', forgoneAmount: '0', + earlyContribution: '0', futureReimbursementClaim: '0', reimbursableAmount: '0', withdrawnAmount: '0', forgoneAmount: '0', }) vi.mocked(approveERC1155ForOperator).mockResolvedValue('0xapprove' as any) mockAccount.address = undefined From 2a63978aad16a49f5724d0d15f17b1e918e5afb1 Mon Sep 17 00:00:00 2001 From: Adam Spitz Date: Sun, 30 Aug 2026 19:59:47 -0400 Subject: [PATCH 6/6] Drop stale conceptspace writes after unmount. File foldReimbursements rounding mismatch as a follow-up. --- TODO.md | 9 +++++++++ ui/src/conceptspace/pages/BrowseStatementsPage.tsx | 3 +++ ui/src/conceptspace/pages/StatementPage.tsx | 3 +++ ui/src/conceptspace/pages/UserProfilePage.tsx | 3 +++ 4 files changed, 18 insertions(+) diff --git a/TODO.md b/TODO.md index e85e1573..fc3c8235 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,15 @@ When an item from this page is done and no longer needs an LLM implementor's att ---- +- Align `foldReimbursements` donation rounding with the contract’s per-share + accumulator (`accumulatedReimbursementPerClaimShare` / `mulDiv`). The fold + currently splits each donation with per-holder `claim * amount / outstanding` + integer division, then subtracts the full donation from `outstanding`, so UI + forgo/withdrawable caps can disagree with on-chain views by leftover wei. + Mirror the contract (scaled accumulator, or live view reads) and add a + remainder-aware test with two holders and a donation that does not divide + evenly. Found in review of `feature/combinator-operand-nonblocking-load`. + - **(Tell)** Refresh `data/seed-implication-evaluations.original-variants.json` against the current implication-attester prompt fingerprint. The prompt now rejects nested-place geographic rollup (Grey County → Ontario is a worked diff --git a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx index 13702fc7..6091d1e3 100644 --- a/ui/src/conceptspace/pages/BrowseStatementsPage.tsx +++ b/ui/src/conceptspace/pages/BrowseStatementsPage.tsx @@ -67,6 +67,9 @@ export function BrowseStatementsPage() { useEffect(() => { loadStatements(sortBy) + return () => { + loadTokenRef.current += 1 + } }, [sortBy, loadStatements]) const handleSortChange = (_: React.MouseEvent, newSort: SortOption | null) => { diff --git a/ui/src/conceptspace/pages/StatementPage.tsx b/ui/src/conceptspace/pages/StatementPage.tsx index e2d94146..7f7fcb3d 100644 --- a/ui/src/conceptspace/pages/StatementPage.tsx +++ b/ui/src/conceptspace/pages/StatementPage.tsx @@ -122,6 +122,9 @@ export function StatementPage() { useEffect(() => { loadStatementData() + return () => { + loadTokenRef.current += 1 + } }, [loadStatementData]) const handleBeliefChanged = useCallback(() => { diff --git a/ui/src/conceptspace/pages/UserProfilePage.tsx b/ui/src/conceptspace/pages/UserProfilePage.tsx index 17e289fa..b09f9fde 100644 --- a/ui/src/conceptspace/pages/UserProfilePage.tsx +++ b/ui/src/conceptspace/pages/UserProfilePage.tsx @@ -103,6 +103,9 @@ export function UserProfilePage() { useEffect(() => { loadUserData() + return () => { + loadTokenRef.current += 1 + } }, [loadUserData]) const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {