simocracy-v2 · proposal-context-sidecar
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.
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.
| Phase | Commit | Files | Description |
|---|---|---|---|
| 1 | 1921dd3 | 3 | Lexicon JSON + indexer copy + types/COLLECTIONS + lib/proposal-context.ts resolver with trust gate |
| doc | 76ecf43 | 1 | plans/PLAN_proposal_context.md — full Phase 1–5 rollout plan |
| 2 | f6dd334 | 6 | Form-write hooks (gathering & FtC SF) + /api/proposal-context endpoint + fetchActivityCidByUri |
| 3 | cce8a86 | 4 | Dual-read sidecar swap in /proposals, /stats, and proposals-feed.tsx (sidecar-first, hashtag fallback) |
| 4 (script) | 87062ed | 2 | Idempotent backfill script scripts/backfill-proposal-context.ts |
| 4 (run) | n/a | n/a | Backfill executed. Wrote 17 sidecars to facilitator's PDS — dry-run + real run, scanned=883, errors=0, idempotent. |
| 5 | 5a2f513 | 5 | Sidecar-driven Hyperindexer fetchers: fetchAllSimocracyProposalContexts(), fetchActivitiesByUris(), pivoted fetchSimocracyProposals + fetchProposalsByFloorTag + new fetchProposalsForGathering(uri); filterLegalProposalsForOverview takes contextKeyByUri |
| 5 | cdd7e4c | 14 | Drop 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.
satyam2.climateai.orgTested on the Vercel production deploy (simocracy-v2-jorfn0t1a.vercel.app, aliased to simocracy.org) — same code path real users hit.
simocracy.org
satyam2.climateai.org, password 1
simocracy.org Bluesky + Repository + Authenticate scopes
MY SIMS and the satyam2 avatar; sims walking on the hero
/events baseline — 6 existing gatherings before the smoke testIssue: 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
/proposals — both the dual-write and sidecar-only proposals resolve their parent gathering
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.
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.
org.simocracy.gatheringat://did:plc:cpoagodpqrgs4t7thi5z37uf/org.simocracy.gathering/3ml5kam5hm62a
CIDbafyreifcpednc6cmhyiqiez6vfpu342erbyobsjfmygiiyjuznb442wram
NamePhase 1-4 Smoke Test 2026
Typegovernance
Indexerindexed via tap firehose
org.hypercerts.claim.activityat://.../org.hypercerts.claim.activity/3ml5kamgqi62a
CIDbafyreicef4wagd4rqb5pfjx3bc3cmyf5by5ngxfcowvu2zfyn7ph5amdbi
TitleSmoke-test proposal — proposalContext verification
shortDescription“…#gathering-3ml5kam5hm62a” (carries the legacy hashtag for dual-read)
org.simocracy.proposalContextIndexer 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"
}
at://.../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
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.
The dual-write proposal (sidecar + hashtag) shows “IN PHASE 1-4 SMOKE TEST 2026” on /proposals. Total feed count went from 14 to 15.
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.
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.
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.
agent-browserThe 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.
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.
satyam2.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)
collectionThe 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"}}
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.
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.
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.
5a2f513 (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
Vercel auto-deployed both commits within 3 minutes of push. Verified live as satyam2.climateai.org on https://simocracy.org across four pages.
/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.
#gathering-* tag; it can only reach the feed via the new sidecar-driven seed in fetchSimocracyProposals. Phase 5 regression check — pass.
fetchProposalsByFloorTag(14). Other floors (F12, F11, F9, F7, F6, F4) all show their expected proposal counts in the floor list.
/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.
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.
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.
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.
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.
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.
1921dd3 → cdd7e4c — 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
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.resolveProposalContexts. Code review confirms the gate; empirical verification is still pending.shortDescription — see Findings. Not blocking.