gainforest-bumicerts · alpha-fund-walkthrough
End-to-end run of alpha.fund.gainforest.app as a Nature Steward — login via climateai.org PDS, build a Bumicert through the 5-step wizard, and verify what actually lands on the Personal Data Server. Login + early steps work beautifully; the create flow is gated by an indexer-coverage bug that prevents the final publish.
The good. The marketing landing, marketplace, and OAuth flow are excellent. Visual design is editorial and confident, the multi-domain handle picker is the cleanest AT-Proto sign-in I have used, and the create wizard's three-pane layout (form · live preview · contextual tips) is genuinely a pleasure to fill in.
The blocker. A Bumicert was not published to the PDS. The wizard's step 3 ("Site Details") requires at least one site boundary; clicking Add a site writes an app.certified.location record to the PDS successfully, the modal even shows a green "Site added successfully" toast — but the new site never appears in the form's site list, so the user can't select it, so step 3 fails validation, so Continue stays disabled. The bumicert itself (org.hypercerts.claim.activity) is therefore never written. The root cause is on the read side: the GainForest indexer that the form reads from has not crawled this user's records — it returns [] for locations.list for did:plc:a6nwvv5treei7trkplq263io even though two records are present on the PDS.
Other findings. The handle the brief gave (satyam1.climateai.org) does not resolve; the working handle is satyam.climateai.org. The Bsky-AppView contributor search can't find non-Bsky users by handle. And there are stale records on this user's PDS from a prior schema (app.gainforest.organization.site, app.gainforest.organization.info) that the new app no longer surfaces — graceful migration would help.
climateai.org)com.atproto.repo.listRecords on climateai.org/api/indexer/trpc/locations.listThe marketing landing is the strongest part of the product. A misty forest hero, an editorial typographic mix (Cormorant Garamond italic for emphasis, neutral sans for body), and a slow scroll choreography that pays off the project's "plain backgrounds, rich experiences" design philosophy. Three feature pillars (verified · direct · decentralized), a two-card "Choose your path" split for funders vs. nature stewards, an accordion explaining what a Bumicert is, and a clean editorial footer.




Click Launch App → routed straight to /explore. A persistent left rail (Bumicerts · Organizations · Leaderboard, plus a Manage section gated behind sign-in), a serif page-title pattern ("Discover Regenerative Impact"), search + sort controls, a horizontally scrollable tag chip strip, and a 4-up grid of project cards. Cards are dense without being noisy: image, project name, description, badges (project type), and parent platform ("Ecocertain").


The sign-in modal opens with two tabs: Email and Handle. The Handle tab is the most considered AT-Proto sign-in I have seen — a username box plus a domain dropdown that defaults to .gainforest.id but lets you pick climateai.org, certified.app, bsky.social, or a custom server.


.gainforest.id.
climateai.org. The pre-curated list is the right call for an alpha audience.
satyam1.climateai.org" — small touch, removes ambiguity.The brief gave satyam1.climateai.org with password 1. That handle does not resolve on the AT-Proto network. Verified independently:
$ dig +short TXT _atproto.satyam1.climateai.org
(empty)
$ curl https://satyam1.climateai.org/.well-known/atproto-did
(no response — DNS does not resolve the host)
$ curl 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=satyam1.climateai.org'
{"error":"InvalidRequest","message":"Unable to resolve handle"}
The working handle is satyam.climateai.org (did:plc:a6nwvv5treei7trkplq263io) — that's almost certainly the intended account. Once the wizard told me "Username not found. Please check your username and server." I switched to that handle and the flow continued. Worth noting two things on the app side:
satyam.climateai.org?" by querying near-neighbours on the same PDS, but that's a polish ask.

satyam.climateai.org — the live "Signing in as …" confirms the resolved identity.Continue redirects to https://climateai.org/oauth/authorize?... — a standard AT-Proto OAuth password screen, with a security warning to verify the domain before entering the password (✓), language switcher, and a "Remember this account on this device" toggle. After the password, an authorize screen explains what the app gets: Bluesky (manage profile/posts/likes), Repository (create, update, and delete any public record), Authenticate (perform authenticated actions on any service). All three scopes have hover help.


After Authorize the page returns to /auth/complete and a role-picker modal appears: Funder, Nature Steward, or I'll decide later. I picked Nature Steward to enter the create flow.


Skipping the org setup and clicking "Bumicerts" under Manage in the left rail (the app does not hard-block this), then "Create new" → "Get Started" → /bumicert/create/0. The wizard is genuinely well-designed: a 5-step header (Cover · Impact · Site · Review · Publish), a left form panel, a right-hand pane that splits into Preview Bumicert and Tips for this section, and a sticky bottom toolbar with Save as Draft and Continue.


Filled the title ("Pi-Eval Test Bumicert — Reforestation walk", 42/50 chars), checked Ecological Restoration + Agroforestry, kept the default date range, and uploaded a cover image (a Unsplash forest JPG). The preview pane updated live with the cover, the contributor name, and the title — and the step header gets a green check.

Two fields, both required: a Tiptap rich-text "Your Impact Story" (with image, video, headings, lists, code, quote, embed-YouTube) and a 3000-char short description with an "AI Generate" button. The 80-char short description I typed previewed correctly. Tips on the right are scenario-specific ("Tell your story in your own words…").


Three sub-sections: List of Contributors, Site Boundaries, Permissions. This is where the run came apart.
Two tabs: Search Users (typeahead) and Enter Name or ID (free-text). Searching for the string satyam in Search Users returns five real Bluesky users — none of which are the logged-in user. Searching the exact handle satyam.climateai.org (i.e. my own handle) returns "No results found."

.bsky.social / .net hits, but the logged-in satyam.climateai.org is missing.
The Search Users tab appears to query the public Bluesky AppView's actor search, which only indexes accounts on the Bluesky AppView. climateai.org, certified.app, and any other PDS users that aren't surfaced through Bsky's appview will not show up — including the user who is currently signed in. For a product whose main pitch is "the same technology that powers Bluesky" as a way to reach any ATProto identity, this is a sharp demo edge: people will search for themselves first.
The Enter Name or ID tab is the documented escape hatch — pasting did:plc:a6nwvv5treei7trkplq263io directly does add the contributor — but the resolved handle initially renders as handle.invalid (see screenshot 24); only after the page is reloaded does the same row render as satyam.climateai.org. The optimistic resolver path doesn't fall back to the user's PDS for handle resolution.

handle.invalid @handle.invalid until the page is reloaded.
satyam.climateai.org. Same row, same data, different render.Clicking Add a site opens a modal with three inputs: site name, GeoJSON upload (or paste), and a "Draw a site" CTA that opens a Mapbox-powered polygon drawing modal. I uploaded a tiny one-feature GeoJSON polygon and named it "Pi-Eval Test Site". The modal showed a green check + "Site added successfully". I closed the modal expecting the new site to appear in the form's list — and it didn't. "No site found." Same with a second attempt named "Pi-Eval Site v2".




Even after a full page reload, the form's site list is still empty:

/bumicert/create/36: state is preserved, contributor row resolves correctly, but Site Boundaries is still empty.
/upload/sites) — also "No sites yet". So this isn't a per-bumicert filter; it's the global site list that's empty.Forcing past step 3 with checkboxes ticked drops you on the Review screen. Cover Details ✓, Impact Details ✓, Site Details flagged with "The following fields are missing: Site Boundaries, Terms and Conditions", and the publish-stage Continue button is disabled. There is no override.

Direct check against the user's PDS via com.atproto.repo.describeRepo + listRecords:
$ DID=did:plc:a6nwvv5treei7trkplq263io
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq .collections
[
"app.certified.location",
"app.gainforest.organization.defaultSite",
"app.gainforest.organization.info",
"app.gainforest.organization.site"
]
The two new sites I created during this run are present as app.certified.location records:
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.certified.location&limit=10" | jq '.records[] | {uri, name: .value.name, createdAt: .value.createdAt}'
{
"uri": "at://did:plc:a6nwvv5treei7trkplq263io/app.certified.location/3mklwqshg622a",
"name": "Pi-Eval Site v2",
"createdAt": "2026-04-29T01:22:14.469Z"
}
{
"uri": "at://did:plc:a6nwvv5treei7trkplq263io/app.certified.location/3mklwie2rfk2a",
"name": "Pi-Eval Test Site",
"createdAt": "2026-04-29T01:17:30.963Z"
}
The shape of one record (the GeoJSON polygon I attached as a 226-byte blob, plus name + createdAt + lpVersion):
{
"$type": "app.certified.location",
"name": "Pi-Eval Test Site",
"srs": "https://epsg.io/3857",
"locationType": "geojson-point",
"location": {
"$type": "org.hypercerts.defs#smallBlob",
"blob": {
"$type": "blob",
"ref": { "$link": "bafkreiepuujnsg2hk7jgrjdqn6yiatga7pdtkr4pe75ci2nk7lqvm34huq" },
"mimeType": "application/geo+json",
"size": 226
}
},
"createdAt": "2026-04-29T01:17:30.963Z",
"lpVersion": "1.0.0"
}
So the write path of the Add-Site mutation works. The bumicert itself, however, was not written. There are zero org.hypercerts.claim.activity records on the repo (the lexicon a Bumicert maps to per org/hypercerts/claim/activity.json):
$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=org.hypercerts.claim.activity" | jq
{ "records": [] }
And after "Save as Draft" the URL becomes /bumicert/create/36 but the PDS still has zero new collections — drafts are stored on the backend (presumably the indexer DB), not on the PDS:
$ for col in app.bumicerts.draft app.bumicerts.application org.hypercerts.claim.activity.draft app.bumicerts.funding.config; do
echo -n "$col: "
curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=$col" | jq '.records | length'
done
app.bumicerts.draft: 0
app.bumicerts.application: 0
org.hypercerts.claim.activity.draft: 0
app.bumicerts.funding.config: 0
I instrumented window.fetch in the page and confirmed the form's site list is fed by the indexer-side tRPC route locations.list:
GET /api/indexer/trpc/locations.list?batch=1&input={"0":{"json":{"did":"did:plc:a6nwvv5treei7trkplq263io"}}}
→ [{ "result": { "data": { "json": [] } } }]
Same DID, three different views:
| Source | app.certified.location records |
|---|---|
climateai.org PDS (com.atproto.repo.listRecords) | 2 |
Bumicerts indexer (locations.list) | 0 |
Bumicerts indexer (organization.byDid) | 0 (returns {org: null, activities: []}) |
The bumicerts wizard reads sites via the indexer:
// apps/bumicerts/app/(marketplace)/bumicert/create/[draftId]/_components/Steps/Step3.tsx
const { data: sitesResponse, isPending, isPlaceholderData, error } =
indexerTrpc.locations.list.useQuery({ did: auth.user?.did ?? "" });
And the Add-Site modal does an optimistic local cache update to paper over indexer lag:
// components/global/modals/upload/site/editor.tsx
const result = await addMutation({ name, shapefile: shapefileData });
await indexerUtils.locations.list.cancel({ did });
indexerUtils.locations.list.setData({ did }, (previousSites) => {
const nextSite = buildOptimisticCertifiedLocation({ did, result, previewUrl });
…
return [nextSite, …deduped];
});
onCreated?.({ uri: result.uri, cid: result.cid, rkey: result.rkey });
setIsCompleted(true);
The optimistic cache update should make the new site visible in the form list immediately. In practice, after Close → step 3 still says "No site found.", and after a hard reload the indexer still returns [], so the optimistic snapshot is the only place the site ever existed in this session. Two bugs to disentangle:
useQuery — possibly because indexerUtils and indexerTrpc have different cache keys (e.g. one is canonicalising the DID lower-case, the other keeping the form's original casing), or the form is on a different query observer that re-fetches and loses the optimistic snapshot.locations.list({ did: 'did:plc:suxrggijupvrlarm7qrxnjbx' }) for an account on bsky.social also returns 0, so the indexer is broadly behind on at least these two PDSes.The bumicert create wizard reads its required site-boundary list from the indexer, not the user's PDS. For a user whose PDS records have not been indexed (which appears to be the case for at least climateai.org), the wizard has no path forward — they can repeatedly create sites that succeed at the PDS layer, but the form will never see them. This is the run's blocker and the reason no Bumicert was published. Two practical fixes:
com.atproto.repo.listRecords against the authed user's PDS for their own sites, so the form is never blocked by indexer coverage. The indexer's job is global discovery; user-private edit flows should not depend on it.To sanity-check that the read paths work for an indexed Bumicert, I opened Tamagochi at Riverside from the marketplace. Detail page is well-built: tab bar (Description · Site Boundaries · Donations · Timeline), clean serif heading, image gallery, donate CTA in the right margin. The Donations tab shows $279.83 USD raised, 1 anonymous donor — actual on-chain funding records came through. Site Boundaries was empty ("No sites linked to this bumicert") and Timeline said "No evidence uploaded yet", which match real upstream data — they're correct empty states, not bugs.




Step 3 reads the user's site list via the bumicerts indexer (locations.list); the indexer returns [] for this DID even though the user has 2 app.certified.location records on climateai.org PDS (and 2 stale app.gainforest.organization.site records). New sites I create during the session also fail to appear, despite the optimistic-cache code in the editor modal. Net result: the publish path is gated and no Bumicert is written. Fix: read self-owned sites from the user's PDS as a fallback / source of truth, and verify the optimistic cache write reaches the form's useQuery.
Search Users typeahead doesn't find satyam.climateai.org, satyam.climateai.org, or any non-Bsky-AppView handle — including the logged-in user. Demo edge case will bite the moment a Nature Steward tries to add themselves first.
handle.invalid until reload UXAdding a contributor by DID via the Enter Name or ID tab succeeds, but the row label is "handle.invalid @handle.invalid" until the page is reloaded — at which point it correctly resolves to satyam.climateai.org. The handle resolver should accept a PDS-side fallback.
Brief said satyam1.climateai.org with password 1; that handle has no DNS or well-known proof and Bsky's resolver returns InvalidRequest. Falling back to satyam.climateai.org worked. Mostly an environment / brief issue, but the app's error UX handled it gracefully.
Editorial typography, calm motion, no clutter, sensible empty states. The "choose your path" + "what is a bumicert" pages frame the product better than 90% of climate-funding apps I have seen.
Email / Handle tabs, a curated domain dropdown that includes climateai.org, an inline live-resolved "Signing in as …" preview, clean OAuth consent with plain-language scope summaries. "Username not found" was non-destructive — kept everything I typed.
Three-pane layout (form · live preview · contextual tips), 5-step header with checkmarks, sticky bottom toolbar, draft auto-save with a recoverable URL like /bumicert/create/36. The Add-Site modal even includes a Mapbox-powered polygon drawing UI as an alternative to GeoJSON upload. The whole thing feels like a finished product, which is why the indexer hole is jarring.
Each Add-Site mutation actually wrote a valid app.certified.location record with a 226-byte GeoJSON blob attached. The lexicon, the smallBlob ref, and the createdAt are all well-formed. The bug is on the read side, not the write side.
agent-browser · Chrome (default profile)
Targetalpha.fund.gainforest.app
Source repoGainForest/bumicerts-monorepo
Account usedsatyam.climateai.org (did:plc:a6nwvv5treei7trkplq263io) · brief said satyam1.climateai.org, that handle does not resolve
PDShttps://climateai.org
Indexer routehttps://alpha.fund.gainforest.app/api/indexer/trpc/locations.list
Draft URL/bumicert/create/36 (created during run, never published)
Videofull-walkthrough.webm
Screenshotsassets/*.png (49 captures)
~19 min, no audio. Compressed VP9 1280px-wide. Original 8.9 MB → 6.7 MB.
DID=did:plc:a6nwvv5treei7trkplq263io
# What collections exist on this user's repo
curl -s "https://climateai.org/xrpc/com.atproto.repo.describeRepo?repo=$DID" | jq .collections
# The two sites I created during the run
curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.certified.location&limit=10" | jq
# No bumicert was actually published
curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=org.hypercerts.claim.activity" | jq
# What the form thinks the user owns:
curl -s 'https://alpha.fund.gainforest.app/api/indexer/trpc/locations.list?batch=1&input=%7B%220%22%3A%7B%22json%22%3A%7B%22did%22%3A%22did%3Aplc%3Aa6nwvv5treei7trkplq263io%22%7D%7D%7D' | jq
# → [{ "result": { "data": { "json": [] } } }]
# What the PDS actually has:
curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords?repo=did:plc:a6nwvv5treei7trkplq263io&collection=app.certified.location" | jq '.records | length'
# → 2