simocracy-v2 · gathering-proposal-leaflet-doc-crash
A user reported that opening a proposal from the GainForest Data Council village threw a runtime error. The crash was caused by a budget parser I’d shipped two commits earlier calling String.prototype.indexOf on a Leaflet document object — the Hyperindexer returns proposal descriptions in that shape, not as plain strings.
Headline. Hyperindexer returns org.hypercerts.claim.activity.description as a Leaflet doc { blocks: [{ block: { plaintext: "…" } }] } for newer proposals — confirmed by direct GraphQL query. The recently-added parseDescriptionWithBudget() in lib/budget-items.ts assumed a string and called .indexOf on the input, throwing TypeError: text.indexOf is not a function the moment the proposal modal mounted. Fix: a new descriptionToText() helper joins all pub.leaflet.blocks.text blocks into plain text; both the budget parser and the S-Process API forwarders normalise through it. The page now mounts cleanly and the budget block is parsed back out and rendered as a structured “Requested Budget” section.
The actual proposal record on this gathering, fetched via Hyperindexer's typed GraphQL endpoint:
$ curl -s -X POST 'https://api.hi.gainforest.app/graphql' -H 'Content-Type: application/json' \
-d '{"query":"query { orgHypercertsClaimActivity(where: { shortDescription: { contains: \"#gathering-3miucq2jbzo2s\" } }, first: 10) { edges { node { uri title description } } } }"}'
{
"data": { "orgHypercertsClaimActivity": { "edges": [{
"node": {
"uri": "at://did:plc:qc42fmqqlsmdq7jiypiiigww/org.hypercerts.claim.activity/3mkcmuv7x5l23",
"title": "Pet Dogs",
"description": {
"blocks": [
{ "block": {
"$type": "pub.leaflet.blocks.text",
"plaintext": "━━━ Budget Request ━━━\n• Dogs — $1,000\n• Cats — $1,964\n━━━ Total: $2,964 ━━━"
} }
]
}
}
}] } }
}
The full payload is committed alongside this report at assets/hyperindexer-payload.json. Feeding the same payload through the OLD vs NEW parser confirms the diagnosis end-to-end:
$ node assets/repro.mjs
typeof description: object
shape: {"blocks":[{"block":{"$type":"pub.leaflet.blocks.text","plaintext":"━━━ Budget Request ━━━\n• …
[OLD parser]
✗ THROWS: text.indexOf is not a function
[NEW parser]
✓ text: "━━━ Budget Request ━━━\n• Dogs — $1,000\n• Cats — $1,964\n━━━ Total: $2,964 ━━━"
parseDescriptionWithBudget(object) throws TypeErrorWhat broke. Two commits ago I added a Requested Budget badge + structured display that parses the budget block out of proposal.description. The parser body was effectively text.indexOf("━━━ Budget Request ━━━") on whatever the caller passed in. RawNode.description was typed as string in gathering-world.tsx but the Hyperindexer returns it as { blocks: […] }, so the call exploded inside the React render and crashed the proposal detail modal as soon as the user opened one.
Where it lived. lib/budget-items.ts (parser entrypoints) plus three call sites that hit the parser the moment a proposal renders: the Decisions tab, the proposal preview list, and the proposal detail modal in both components/events/gathering-world.tsx and components/ftc-sf/proposal-detail-modal.tsx.
Why it slipped through CI. tsc --noEmit passed because the description field was annotated as string at the GraphQL boundary; npm run build passed because Next.js doesn’t exercise these code paths. The crash only manifests on click, with a real Hyperindexer response, against a proposal authored after Leaflet adoption.
descriptionToText() normalises the input upstreamThe fix. A small helper in lib/budget-items.ts that:
export function descriptionToText(input: unknown): string {
if (input == null) return ""
if (typeof input === "string") return input
if (typeof input === "object") {
const blocks = (input as Record<string, unknown>).blocks
if (Array.isArray(blocks)) {
const parts: string[] = []
for (const entry of blocks) {
const b = entry && typeof entry === "object"
? (entry as Record<string, unknown>).block : null
if (b && typeof b === "object") {
const text = (b as Record<string, unknown>).plaintext
if (typeof text === "string" && text.length > 0) parts.push(text)
}
}
return parts.join("\n\n")
}
const text = (input as Record<string, unknown>).text
if (typeof text === "string") return text
}
return ""
}
Both parseDescriptionWithBudget and parseRequestedBudget now accept unknown and run their input through this helper before any string operations, so they never crash on object inputs. The two S-Process modals (gathering + FtC SF) also normalise descriptions to plain text before forwarding them to /api/s-process/evaluate-batch — previously the AI was receiving stringified JSON in the description slot, which would have hurt MVF reasoning quality even when it didn’t crash.
Verified deployed. Both descriptionToText and the plaintext field name are present in the production JS bundle (/_next/static/chunks/2ea78fa316fbe67a.js, /_next/static/chunks/8f20d65be51350c6.js) on Vercel as of 2026-04-25 02:47 UTC.
The same parser is reachable from components/ftc-sf/proposal-detail-modal.tsx and components/ftc-sf/floor-sidebar.tsx. None of those paths had reproduced the crash yet because the FtC SF tower has older proposals (still string descriptions) plus a few proposal-form-authored ones, but any future Leaflet-doc proposal would have hit the same stack trace. descriptionToText() covers them all.
Post-fix, the proposal detail modal parses the Leaflet doc to plain text, splits the budget block out, and renders it as a typographic “Requested Budget” section with the per-item lines + total. The S-Process AI also gets the requested amount as a first-class numeric field via requestedBudget/requestedBudgetItems on the API payload, so MVF curves can anchor near the asked amount instead of inferring it from prose.
d1f6737 — fix(events): handle Leaflet-doc proposal descriptions in budget parsing
Failing URL/events/did:plc:qc42fmqqlsmdq7jiypiiigww/3miucq2jbzo2s
Repro scriptassets/repro.mjs + assets/repro.tape
Runcd assets && node repro.mjs — OLD parser throws, NEW parser passes
Re-rendercd assets && vhs repro.tape
Indexer payloadassets/hyperindexer-payload.json
Gathering recordassets/gathering-record.json
Quality gatesnpm run lint · npx tsc --noEmit · npm run build — all pass