simocracy-v2 · gathering-proposal-leaflet-doc-crash

Proposal modal crash from Leaflet-doc descriptions

@daviddao

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.

Date: 2026-04-25 · Status: fixed · Commit: d1f6737 · URL: /events/did:plc:qc42fmqqlsmdq7jiypiiigww/3miucq2jbzo2s


Headline result

1
Production crash reproduced
1
Fix shipped + deployed
3
Files patched
0
New deps added

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.

Reproducing the crash

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 ━━━"

Walkthrough

GainForest Data Council gathering rendering on simocracy.org with the village background and three sims
01The gathering page on production after the fix — background loads, three council sims (jigme khesar namgyal wangchuck, ClawBot, Einstein) walk freely. Pre-fix this page mounted but the proposal modal would crash on open; post-fix the modal mounts and the budget block renders as a structured “Requested Budget” section instead of being interpreted as raw markdown.

Findings

FAIL — parseDescriptionWithBudget(object) throws TypeError

What 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.

FIXED — descriptionToText() normalises the input upstream

The 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.

INFO — same fix benefits FtC SF

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.

PASS — Requested Budget renders as structured data

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.

Reproducing

RepositoryGainForest/simocracy-v2 Fix commitd1f6737 — 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