simocracy-v2 · hypercerts-description-union-fix
description & workScope union fixA three-project audit against the upstream Hypercerts lexicon at hyperscan.dev. Two of the three were silently writing the wrong shape — fixed, regression-tested, all green.
Headline. The upstream org.hypercerts.claim.activity lexicon defines description, workScope, and contributorIdentity as unions — each variant is identified by a $type discriminator. flue-simocracy already wrapped its writes correctly; pi-simocracy and simocracy-v2 were emitting bare strings (and one un-discriminated Leaflet doc), which lex-gql silently rejects on the way to the indexer. Patched every write site to wrap correctly, made the simocracy-v2 read pipeline union-aware via a centralised descriptionToText() normaliser, and confirmed all three projects still typecheck, lint, and build green.
The audit's source of truth is the curated lexicon at hyperscan.dev/agents/lexicon/org.hypercerts.claim.activity. Three of its fields are unions, not scalars:
$type discriminator on write.| Field | Union members | Required $type discriminator |
|---|---|---|
description |
descriptionString · leaflet linearDocument · strongRef | org.hypercerts.defs#descriptionString — { value, facets? }pub.leaflet.pages.linearDocument — { blocks } |
workScope |
workScopeString · workscope.cel | org.hypercerts.claim.activity#workScopeString — { scope } |
contributorIdentity |
inline contributorIdentity · strongRef | org.hypercerts.claim.activity#contributorIdentity — { identity } |
The Telegram-shim agent's post_proposal tool already wrapped all three union fields with the right $type. A code comment beside the writes documents why bare strings are forbidden — evidence the original author already debugged this against the indexer.
// src/tools/post-proposal.ts — unchanged, used as the reference shape
proposalRecord.description = {
$type: "org.hypercerts.defs#descriptionString",
value: finalDescription,
};
proposalRecord.workScope = {
$type: "org.hypercerts.claim.activity#workScopeString",
scope: workScope.trim(),
};
src/writes.ts · createProposal assigned bare strings to record.description and record.workScope, and forwarded contributor identities as bare strings. lex-gql silently rejects these, so any proposal authored from pi never made it to the indexer (proposals appeared on the user's PDS but were absent from simocracy.org/proposals).
// before — silently dropped on the way to the indexer
record.description = body;
record.workScope = ws;
// after
record.description = { $type: "org.hypercerts.defs#descriptionString", value: body };
record.workScope = { $type: "org.hypercerts.claim.activity#workScopeString", scope: ws };
record.contributors = opts.contributors.map((c) => ({
contributorIdentity: {
$type: "org.hypercerts.claim.activity#contributorIdentity",
identity: c.contributorIdentity,
},
}));
The webapp has two proposal-form components and they disagreed with each other:
components/ftc-sf/proposal-form-dialog.tsx wrote bare strings (same bug as pi-simocracy) for description, workScope, and contributor identities.components/events/gathering-proposal-form.tsx built a Leaflet document for description but forgot the top-level $type: "pub.leaflet.pages.linearDocument", so the lex validator couldn't tell which union variant the value was.On the read side, HypercertActivity.description was typed as string in lib/lexicon-types.ts — a half-truth that worked only because the indexer happened to flatten descriptionString back to a scalar. Anyone calling .slice() on it (e.g. generateMetadata on the proposal permalink page) would crash the moment a real Leaflet doc came through.
Fix:
$type (and the Leaflet form now sets the top-level discriminator).HypercertActivity.description is now typed as ActivityDescription = string | DescriptionString | LeafletDescription | StrongRef.descriptionToText() in lib/budget-items.ts now handles all four shapes (string, descriptionString, Leaflet doc, legacy {text})..slice, edit-form pre-fill, S-Process LLM payload) was wrapped through the normaliser.// lib/budget-items.ts — single source of truth, used at every read site
export function descriptionToText(input: unknown): string {
if (input == null) return ""
if (typeof input === "string") return input // indexer-flattened
if (typeof input === "object") {
const obj = input as Record<string, unknown>
if (typeof obj.value === "string") return obj.value // descriptionString
if (Array.isArray(obj.blocks)) { // leaflet doc
return obj.blocks
.map(e => (e as any)?.block?.plaintext)
.filter((s: unknown): s is string => typeof s === "string")
.join("\n\n")
}
if (typeof obj.text === "string") return obj.text // legacy
}
return ""
}
| Project | File | Change |
|---|---|---|
| flue-simocracy | src/tools/post-proposal.ts | (no change — reference) |
| flue-simocracy | scripts/check-proposal.ts | debug script: union-aware printout for description/workScope |
| pi-simocracy | src/writes.ts | createProposal: wrap description, workScope, every contributorIdentity with $type |
| simocracy-v2 | components/ftc-sf/proposal-form-dialog.tsx | wrap union writes; map contributors to the inline-contributor variant |
| simocracy-v2 | components/events/gathering-proposal-form.tsx | add top-level $type: "pub.leaflet.pages.linearDocument" on textToLeafletDocument() |
| simocracy-v2 | lib/lexicon-types.ts | introduce ActivityDescription union; retype HypercertActivity.description |
| simocracy-v2 | lib/budget-items.ts | extend descriptionToText() to handle the descriptionString variant |
| simocracy-v2 | app/proposals/[did]/[rkey]/page.tsx | normalise via descriptionToText() before .slice(0, 160) in generateMetadata |
| simocracy-v2 | components/ftc-sf/floor-sidebar.tsx | edit-form pre-fill: normalise to text |
| simocracy-v2 | components/ftc-sf/ftc-s-process-modal.tsx | LLM evaluator payload: normalise to text |
| simocracy-v2 | components/events/gathering-world.tsx | edit-form pre-fill: normalise to text |
| simocracy-v2 | components/events/gathering-s-process-modal.tsx | LLM evaluator payload: normalise to text |
Each project was driven through its full local verification chain. The terminal recording below replays the runs in sequence:
Reproducible from typecheck.tape via vhs assets/typecheck.tape.
| Project | Stack | Verification | Result |
|---|---|---|---|
simocracy-v2 |
Next.js 15 webapp | npx tsc --noEmit · npm run lint · npm run build |
pass — 16 / 16 routes compiled |
pi-simocracy |
Bun-style TS CLI agent | npx tsc --noEmit |
pass |
flue-simocracy |
Flue agent + Telegram shim | npx tsc --noEmit · npm run build (Flue webhook bundle) |
pass — dist/server.mjs emitted |
flue-simocracy's post_proposal tool was already correct, including a defensive code comment explaining the silent-drop behaviour of lex-gql when bare strings hit a union field. Used as the canonical shape for the other two.
Both were emitting { description: "...", workScope: "..." } as plain strings. The records land on the user's PDS but never propagate to the Hyperindexer, so they don't appear in simocracy.org/proposals. After the fix, all three projects share one wire shape that matches the lexicon.
HypercertActivity.description was typed string, masking the fact that future indexer revisions could surface raw union shapes. Retyped to ActivityDescription and wrapped every direct consumer (.slice, edit-form pre-fill, LLM evaluator payload) through descriptionToText() so the read pipeline now tolerates all four shapes.
The Hyperindexer's GraphQL still returns description as a scalar today (it flattens descriptionString back to a string for backwards compatibility). The post-fix simocracy-v2 read pipeline is forward-compatible with both: scalars pass through unchanged, raw union objects get normalised. No coordinated indexer change required.
cd simocracy-v2 && npx tsc --noEmit && npm run lint && npm run build
Verify pi-simocracycd pi-simocracy && npx tsc --noEmit
Verify flue-simocracycd flue-simocracy && npx tsc --noEmit && npm run build
Re-render terminalvhs assets/typecheck.tape
Lexicon screenshotassets/01-lexicon-claim-activity.png
Smoke recordingtypecheck.webm