simocracy-v2 · hypercerts-description-union-fix

Hypercerts description & workScope union fix

@daviddao

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

Date: 2026-05-10 · pass · Scope: simocracy-v2 · pi-simocracy · flue-simocracy


Headline result

3 / 3
Projects compliant
2
Found broken
1
Read-side gap fixed
3
Union fields normalised

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 lexicon

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:

Hypercerts claim.activity lexicon properties — workScope and description as unions
01The three union fields. Each variant must be tagged with a $type discriminator on write.
FieldUnion membersRequired $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 }

Audit — per project

flue-simocracy · already correct

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(),
};

pi-simocracy · fixed

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,
  },
}));

simocracy-v2 · fixed across two write paths + the read pipeline

The webapp has two proposal-form components and they disagreed with each other:

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:

  1. Both forms wrap their union writes with $type (and the Leaflet form now sets the top-level discriminator).
  2. HypercertActivity.description is now typed as ActivityDescription = string | DescriptionString | LeafletDescription | StrongRef.
  3. descriptionToText() in lib/budget-items.ts now handles all four shapes (string, descriptionString, Leaflet doc, legacy {text}).
  4. Every direct consumer (.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 ""
}

Where the fixes landed

ProjectFileChange
flue-simocracysrc/tools/post-proposal.ts(no change — reference)
flue-simocracyscripts/check-proposal.tsdebug script: union-aware printout for description/workScope
pi-simocracysrc/writes.tscreateProposal: wrap description, workScope, every contributorIdentity with $type
simocracy-v2components/ftc-sf/proposal-form-dialog.tsxwrap union writes; map contributors to the inline-contributor variant
simocracy-v2components/events/gathering-proposal-form.tsxadd top-level $type: "pub.leaflet.pages.linearDocument" on textToLeafletDocument()
simocracy-v2lib/lexicon-types.tsintroduce ActivityDescription union; retype HypercertActivity.description
simocracy-v2lib/budget-items.tsextend descriptionToText() to handle the descriptionString variant
simocracy-v2app/proposals/[did]/[rkey]/page.tsxnormalise via descriptionToText() before .slice(0, 160) in generateMetadata
simocracy-v2components/ftc-sf/floor-sidebar.tsxedit-form pre-fill: normalise to text
simocracy-v2components/ftc-sf/ftc-s-process-modal.tsxLLM evaluator payload: normalise to text
simocracy-v2components/events/gathering-world.tsxedit-form pre-fill: normalise to text
simocracy-v2components/events/gathering-s-process-modal.tsxLLM evaluator payload: normalise to text

Smoke tests

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.

ProjectStackVerificationResult
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) passdist/server.mjs emitted

Findings

What worked — reference implementation

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.

Fixed — pi-simocracy and simocracy-v2 silently incompatible writes

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.

Fixed — read-side type gap in simocracy-v2

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.

Note — indexer-side flattening

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.

Reproducing

Lexicon sourcehyperscan.dev/agents/lexicon/org.hypercerts.claim.activity Verify simocracy-v2cd 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