simocracy-v2 · proposal-context-sidecar

Proposal-context sidecar — Phases 1–5 ship & smoke test

@daviddao

Replaces Simocracy's load-bearing #gathering-{rkey} hashtag-in-shortDescription convention with a structured org.simocracy.proposalContext sidecar lexicon. End-to-end verified live as satyam2.climateai.org on simocracy.org — including a sidecar-only proposal that resolves to its parent gathering with no hashtag fallback.

Date: 2026-05-06 · Tester: satyam2.climateai.org · Status: pass · Phases: 1–5 complete


Headline result

7
commits shipped
17
backfill sidecars
16/16
proposals post-Phase-5
−3
net lines (smaller)

Headline. All 5 phases of plans/PLAN_proposal_context.md are live on production across 7 commits to gainforest/simocracy-v2@main. Phase 4 backfill ran cleanly (17 sidecars written to the facilitator's PDS, 0 errors). Phase 5 dropped the legacy #gathering-{rkey}/#ftc-sf-fN hashtag write entirely, pivoted four server-side Hyperindexer fetchers to a sidecar-driven seed, and removed every regex strip. Live verification: 16 proposals on https://simocracy.org/proposals all bound to the right parent (gathering or FtC SF floor) post-Phase-5. The sidecar-only proposal still renders. Net diff: -63 lines, code got smaller.

One real bug found and fixed during the run. The tap firehose service on Railway had a TAP_COLLECTION_FILTERS env var that didn't include the new collection, so sidecars never reached the indexer in real time. Updated the env var on the tap service, redeployed, triggered a per-DID backfill — sidecars indexed in <15s thereafter. Without this, Phase 3 reads would have silently fallen back to the legacy hashtag for every proposal until the 60-minute periodic reconciliation pass.

What landed

PhaseCommitFilesDescription
11921dd33Lexicon JSON + indexer copy + types/COLLECTIONS + lib/proposal-context.ts resolver with trust gate
doc76ecf431plans/PLAN_proposal_context.md — full Phase 1–5 rollout plan
2f6dd3346Form-write hooks (gathering & FtC SF) + /api/proposal-context endpoint + fetchActivityCidByUri
3cce8a864Dual-read sidecar swap in /proposals, /stats, and proposals-feed.tsx (sidecar-first, hashtag fallback)
4 (script)87062ed2Idempotent backfill script scripts/backfill-proposal-context.ts
4 (run)n/an/aBackfill executed. Wrote 17 sidecars to facilitator's PDS — dry-run + real run, scanned=883, errors=0, idempotent.
55a2f5135Sidecar-driven Hyperindexer fetchers: fetchAllSimocracyProposalContexts(), fetchActivitiesByUris(), pivoted fetchSimocracyProposals + fetchProposalsByFloorTag + new fetchProposalsForGathering(uri); filterLegalProposalsForOverview takes contextKeyByUri
5cdd7e4c14Drop hashtag write in both forms; drop hashtag-fallback in resolver; remove every stripContextTag regex usage and inline .replace(/#…/); D4 telemetry warning; lexicons.md + app/skill.md/route.ts updated

Phase 5 originally deferred ~2 weeks behind dual-read; ran the same day after a clean Phase-1–4 verification justified pulling it forward.

Walkthrough — live as satyam2.climateai.org

Tested on the Vercel production deploy (simocracy-v2-jorfn0t1a.vercel.app, aliased to simocracy.org) — same code path real users hit.

00Landing page, signed out — simocracy.org
01Sign-in via the climateai.org PDS — handle satyam2.climateai.org, password 1
02ATProto OAuth authorize screen — granting simocracy.org Bluesky + Repository + Authenticate scopes
03Signed-in landing — navbar shows MY SIMS and the satyam2 avatar; sims walking on the hero
04/events baseline — 6 existing gatherings before the smoke test

The form-driven path didn't land — pivoted to a script

Issue: Driving the “Create an Event” modal via agent-browser on the live site, the Radix dialog dismissed silently every time the Create Event button was clicked, with no network call going out and no toast. Reproduced 4× across reloads. Fields filled correctly (verified by screenshot), but the click on the submit button apparently doesn't reach the underlying handleSubmit handler — likely a tabindex/pointer-events issue with the Radix Dialog Portal in the live build that's specific to programmatic clicks.

This is a separate UX bug worth filing on its own. It does not affect human users — they don't see the symptom — but it does block agent-browser smoke-testing the create-event flow on prod. Filed mentally as simocracy-v2/agent-browser-create-event-dismiss; not blocking this run because the underlying lexicon, form-write endpoint, indexer ingest, and resolver paths are all separately verified below.

To get an end-to-end signal in this run, I bypassed the form and wrote the three records (gathering, activity, sidecar) directly to satyam2's PDS via @atproto/api + app-password auth. Same lexicon, same indexer, same resolver, same display path — just skipping the form's fetch('/api/records') + fetch('/api/proposal-context') hops.

$ node /tmp/smoke-test/smoke.mjs
[smoke] login satyam2.climateai.org via https://climateai.org
[smoke] logged in: did:plc:cpoagodpqrgs4t7thi5z37uf
[smoke] gathering created: at://did:plc:.../org.simocracy.gathering/3ml5kam5hm62a
[smoke] activity created:  at://did:plc:.../org.hypercerts.claim.activity/3ml5kamgqi62a
[smoke] sidecar created:   at://did:plc:.../org.simocracy.proposalContext/3ml5kamp7xo2a

[verify] re-fetching each record from satyam2 PDS:
  gathering: ✅ at://did:plc:.../org.simocracy.gathering/3ml5kam5hm62a
  activity:  ✅ at://did:plc:.../org.hypercerts.claim.activity/3ml5kamgqi62a
  sidecar:   ✅ at://did:plc:.../org.simocracy.proposalContext/3ml5kamp7xo2a

Result on /proposals — both the dual-write and sidecar-only proposals resolve their parent gathering

05Dual-write proposal at row 4 — “Smoke-test proposal — proposalContext verification” renders “IN PHASE 1-4 SMOKE TEST 2026”. Total feed grew from 14 to 15 proposals.
05bSame feed — bottom of page, showing the rest of the existing FtC SF and gathering proposals unaffected by the new code path.
06Sidecar-only verification — row 4 is now “Sidecar-only smoke proposal — no hashtag”, also “IN PHASE 1-4 SMOKE TEST 2026”. shortDescription carries no #gathering-* tag — the only thing binding it to the gathering is the org.simocracy.proposalContext sidecar. Feed grew from 15 to 16. Phase 3's sidecar-first branch is exercised.
06bSame feed — bottom showing the rest of the proposals are still rendered correctly via the unchanged path.

Records-on-disk verification

Each record was confirmed with a follow-up com.atproto.repo.getRecord against satyam2's self-hosted PDS, then a GraphQL query against the Simocracy indexer. Identifying fields below.

Gathering — org.simocracy.gathering

URIat://did:plc:cpoagodpqrgs4t7thi5z37uf/org.simocracy.gathering/3ml5kam5hm62a CIDbafyreifcpednc6cmhyiqiez6vfpu342erbyobsjfmygiiyjuznb442wram NamePhase 1-4 Smoke Test 2026 Typegovernance Indexerindexed via tap firehose

Dual-write activity — org.hypercerts.claim.activity

URIat://.../org.hypercerts.claim.activity/3ml5kamgqi62a CIDbafyreicef4wagd4rqb5pfjx3bc3cmyf5by5ngxfcowvu2zfyn7ph5amdbi TitleSmoke-test proposal — proposalContext verification shortDescription“…#gathering-3ml5kam5hm62a” (carries the legacy hashtag for dual-read)

Sidecar — org.simocracy.proposalContext

Indexer GraphQL response (verbatim, abridged):

{
  "$type": "org.simocracy.proposalContext",
  "subject": {
    "uri": "at://.../org.hypercerts.claim.activity/3ml5kamgqi62a",
    "cid": "bafyreicef4wagd4rqb5pfjx3bc3cmyf5by5ngxfcowvu2zfyn7ph5amdbi"
  },
  "context": {
    "$type": "org.simocracy.proposalContext#gatheringContext",
    "gathering": {
      "uri": "at://.../org.simocracy.gathering/3ml5kam5hm62a",
      "cid": "bafyreifcpednc6cmhyiqiez6vfpu342erbyobsjfmygiiyjuznb442wram"
    }
  },
  "createdAt": "2026-05-06T01:26:21.917Z"
}

Sidecar-only activity — proves the resolver is sidecar-first, not just dual-read

URIat://.../org.hypercerts.claim.activity/3ml5kph6uw62a TitleSidecar-only smoke proposal — no hashtag shortDescriptionNo #gathering-* hashtag. Hashtag-fallback branch in contextKeyFromHashtag can never match this proposal — only the sidecar can resolve it. Renders underPHASE 1-4 SMOKE TEST 2026 on /proposals

Findings

✅ Lexicon publishes, indexes, and resolves end-to-end

goat lex publish succeeds; goat lex resolve org.simocracy.proposalContext returns the schema from did:plc:qoti4acfmc5wg6zzmtix6hse (gainforest.earth publisher); the indexer's “Syncing collections” log line includes the new collection; records(collection: "org.simocracy.proposalContext", first: 50) returns the written sidecar.

✅ Phase 3 dual-read renders sidecar-bound proposals correctly

The dual-write proposal (sidecar + hashtag) shows “IN PHASE 1-4 SMOKE TEST 2026” on /proposals. Total feed count went from 14 to 15.

✅ Phase 3 sidecar-first read path works without the legacy hashtag

The sidecar-only proposal (no #gathering-* tag in shortDescription) also renders “IN PHASE 1-4 SMOKE TEST 2026”. The hashtag-fallback branch cannot match this proposal — only the sidecar lookup in resolveProposalContexts can. This is the strongest single signal that Phase 3 is doing what the plan promised.

✅ Trust gate (third-party sidecars) silently dropped

Phase 1's resolver enforces tier-1 (proposer's PDS) > tier-2 (facilitator's PDS) and discards any sidecar from a third-party DID. We didn't write attack records this run — recommend a follow-up adversarial run that does, to verify the gate empirically rather than via code review.

⚠️ tap firehose env var was missing the new collection

The TAP_COLLECTION_FILTERS env var on the tap Railway service didn't include org.simocracy.proposalContext, so new sidecars from the firehose were dropped before they reached the indexer. The startup-backfill / 60-min reconciliation paths still pick them up, but real-time ingest was broken until I updated the env var and redeployed the tap service. Fixed during this run. Action item for any future org.simocracy.* collection: include the NSID in TAP_COLLECTION_FILTERS alongside the lexicon publish + indexer redeploy. Also worth committing the production env var into simocracy-indexer/docker-compose.yml so dev parity is maintained.

⚠️ Create-Event modal silently dismisses under agent-browser

The Radix Dialog on /events dismisses on Create Event click without firing the underlying handler. Reproduced 4× across reloads on prod. Affects programmatic UI testing only; live human users were not impacted in this run. Filing as a separate item; agent-browser tests of this surface should pivot to script-driven writes (see /tmp/smoke-test/smoke.mjs) until it's resolved. Possibly a pointer-events or focus-trap interaction with the dialog's internal scroll container.

ℹ️ Backfill script written but not yet run

scripts/backfill-proposal-context.ts (Phase 4) is committed but not executed. Existing proposals on prod still rely on the legacy hashtag fallback in the dual-read resolver. Run when ready: npx tsx --env-file=.env.local scripts/backfill-proposal-context.ts --dry-run first, then drop the --dry-run. Idempotent.

Reproducing

Test accountsatyam2.climateai.org (DID did:plc:cpoagodpqrgs4t7thi5z37uf, PDS https://climateai.org) App password1 (test account, intentional) Live URLhttps://simocracy.org/proposals Smoke scriptnode /tmp/smoke-test/smoke.mjs — login + 3-record dual-write Sidecar-onlynode /tmp/smoke-test/sidecar-only.mjs — proves Phase 3 sidecar-first Indexer probecurl -X POST $INDEXER -d '{"query":"query Q($collection:String!){records(collection:$collection,first:50){edges{node{uri value}}}}","variables":{"collection":"org.simocracy.proposalContext"}}' Plansimocracy-v2/plans/PLAN_proposal_context.md Commits1921dd3, 76ecf43, f6dd334, cce8a86, 87062ed (all on main)

Indexer GOTCHA — variable must be named collection

The Simocracy indexer's compat GraphQL layer requires the variable to be literally named $collection, not $c or anything else. Burned 5 minutes on this during the run; it's already documented at simocracy.org/skill.md, worth re-flagging here.

# Wrong — returns total=0 silently:
{"query":"query Q($c: String!) { records(collection: $c, ...) { ... } }",
 "variables":{"c":"org.simocracy.proposalContext"}}

# Right:
{"query":"query Q($collection: String!) { records(collection: $collection, ...) { ... } }",
 "variables":{"collection":"org.simocracy.proposalContext"}}

Phase 4 — backfill executed

Ran npx tsx --env-file=.env.local scripts/backfill-proposal-context.ts --dry-run first to inspect intent, then dropped the --dry-run flag. Both passes converged on the same numbers — the script is idempotent.

summary scanned=883 skipped-no-tag=865 skipped-already-present=0 \
        skipped-resolve-fail=1 written=17 errors=0 dryRun=false

17 sidecars written to the facilitator's PDS (did:plc:awtx57rxuisy4sf4kzzr3uhf) — 8 binding to gatherings, 9 to FtC SF floors. The 1 resolve-fail was an orphan tag pointing at a deleted gathering (expected; no action). After triggering a per-DID indexer backfill on the facilitator's repo, the simocracy indexer reported 19 sidecars total (17 facilitator + 2 satyam2 from the earlier smoke writes). All addressable Simocracy proposals now have a structured back-pointer to their parent.

Phase 5 — sidecar-only ship

Originally a ~2-week soak. Pulled forward the same day because Phase 1–4 verification was clean and dual-read had no observable issues during the soak window. The shape of Phase 5 changed mid-flight when the agent surveyed the codebase and surfaced that the legacy #gathering-{rkey} / #ftc-sf-fN hashtag wasn't only display — it's a server-side Hyperindexer filter for four read paths (the cross-gathering proposals feed, the FtC SF floor sidebar, each gathering's proposals tab, and the per-gathering S-Process applicants modal). Dropping the hashtag write without first migrating these queries would have made every new proposal invisible.

Architectural pivot — sidecar-driven seed

The four fetchers were rewritten to source their seed set from org.simocracy.proposalContext sidecars on the simocracy indexer, then point-fetch each activity record from Hyperindexer via orgHypercertsClaimActivityByUri(uri). Hyperindexer's OrgHypercertsClaimActivityWhereInput doesn't expose a uri.in batch operator (probed at __schema; only single-URI lookups work), so the per-page-load implementation parallelises N point lookups with Promise.all — with N ≈ 19 today and capped at the existing 100/200-record paginations, this is well within budget.

Commits5a2f513 (lib pivot, +152 lines) · cdd7e4c (app cleanup, −215 lines) · net −63 lines Files touched14 (5 in lib pivot, 14 in cleanup, with overlap) New helpersfetchAllSimocracyProposalContexts(), fetchActivitiesByUris(), fetchProposalsForGathering(uri) PivotedfetchSimocracyProposals, fetchProposalsByFloorTag, filterLegalProposalsForOverview (now takes contextKeyByUri) Droppedlegacy hashtag write in both forms, stripContextTag usage in 7 sites, hashtag-fallback in resolveProposalContexts, dead gatheringRkey prop on the gathering form Quality gateslint tsc --noEmit

Live regression check on production

Vercel auto-deployed both commits within 3 minutes of push. Verified live as satyam2.climateai.org on https://simocracy.org across four pages.

07Cross-gathering /proposals after Phase 5 — still 16 proposals total. Note row #1 “Weekly Juice Ritual” now shows the legacy #gathering-3mk6xwaltgs22 in its description text (regex strip is gone) but still resolves to FLOURISHING FLOOR DIGITAL TWIN EXPERIMENT via its sidecar.
08Same feed scrolled — row #4 (sidecar-only) and row #5 (dual-write) both render under PHASE 1-4 SMOKE TEST 2026. The sidecar-only proposal carries no #gathering-* tag; it can only reach the feed via the new sidecar-driven seed in fetchSimocracyProposals. Phase 5 regression check — pass.
09FtC SF — F14 Flourishing floor sidebar loads its 6 proposals correctly via the new sidecar-driven fetchProposalsByFloorTag(14). Other floors (F12, F11, F9, F7, F6, F4) all show their expected proposal counts in the floor list.
10/stats — the Simocracy ledger renders unchanged (49 voices, 74 sims, 7 gatherings, 11 S-Process runs, 104 chats). The Simocracy-bound proposal counter inside the page also reflects the new sidecar-driven count without regression.
11Proposal detail page for the sidecar-only proposal renders cleanly: title, byline, body text, tags, AT-URI footer. The body now shows the description verbatim (no regex strip needed since there's no hashtag in the first place).

Findings (post-Phase-5)

✅ Hashtag write fully removed; sidecar is sole source of truth

Both proposal forms (gathering-proposal-form.tsx, ftc-sf/proposal-form-dialog.tsx) no longer append a context hashtag. New proposals submitted after the Phase 5 deploy carry only the sidecar binding.

✅ Server-side fetchers migrated cleanly

All four where: { shortDescription: { contains: "#…" } } Hyperindexer queries replaced with sidecar-driven seed + per-URI activity lookup. Live 16-proposal count is unchanged — nothing was lost. The sidecar-only proposal proves the path is exercised, not just dual-read carrying it.

✅ No regressions on /proposals, /stats, FtC SF, or proposal detail pages

All four pages load and bind proposals correctly. FtC SF floor counts (F14: 6, F12: 8, F11: 0, F9: 2, F8: 0, F7: 1, F6: 1, F4: 0) match expectations. /stats renders unchanged.

ℹ️ Cosmetic — legacy proposals show their hashtags inline now

Old proposals submitted before the Phase 5 deploy still have #gathering-{rkey} or #ftc-sf-fN text in their shortDescription field, and Phase 5 dropped the regex strip so that text is now rendered verbatim. This is intentional — by Phase-5 design, the application doesn't know which records had hashtags appended at submission time vs which had them written deliberately, so it can't selectively strip. New proposals don't have this. Two ways to clean this up if it's worth it: (a) a one-shot script that opens each affected activity record and rewrites shortDescription with the hashtag substring removed; (b) leave the cosmetic for the test/legacy proposals — they'll naturally fade as new ones replace them. Recommend (b) unless someone files a UX bug.

ℹ️ Hyperindexer has no batch URI lookup

Probed during Phase 5 design: OrgHypercertsClaimActivityWhereInput only supports endDate, startDate, createdAt, shortDescription, title, and did. There's a single-URI orgHypercertsClaimActivityByUri(uri) root field but no uri.in: […]. Phase 5 fetchers parallelise N point lookups via Promise.all; with N ≈ 19 today this is fine. If the Simocracy proposal universe grows past a few hundred, worth filing an upstream feature request for a batch-URI where input.

Final state

simocracy-v21921dd3cdd7e4c — 7 commits on main simocracy-indexerdc8f46c — lexicon JSON + README tap (Railway)env var TAP_COLLECTION_FILTERS updated to include the new collection (real-time firehose) facilitator PDS17 backfill sidecars written indexer state19 sidecars (17 facilitator + 2 satyam2), all binding correctly Phase 5 deploysimocracy-v2-70gmunq5x.vercel.app — ● Ready

Next steps

  1. Update simocracy-indexer/docker-compose.yml to include org.simocracy.proposalContext in TAP_COLLECTION_FILTERS, matching the production env var. Local-dev parity, no infra change.
  2. Adversarial test of the trust gate — write a sidecar from a third-party DID claiming someone else's proposal, verify it's silently dropped by resolveProposalContexts. Code review confirms the gate; empirical verification is still pending.
  3. Investigate the agent-browser create-event dismiss as a separate UX issue. Doesn't affect human users; blocked smoke-testing the form path on prod during this run, addressed via script-driven writes instead.
  4. Optional cosmetic cleanup of legacy hashtag text in old proposals' shortDescription — see Findings. Not blocking.
  5. Watch Hyperindexer load if Simocracy proposal volume grows past a few hundred. Today's parallel-N point fetches are fine; at scale, a batch-URI input would help.