gainforest-bumicerts · alpha-fund-walkthrough

Alpha Fund — UX walkthrough & PDS verify

@daviddao

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.

Date: 2026-04-29 · Target: alpha.fund.gainforest.app · Account: satyam.climateai.org · Tool: agent-browser · partial


Headline result

5
Things that worked well
1
Blocker
3
UX issues
3
PDS records written

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.

What I tested


1. Landing & first impressions

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

01Hero. "Verified Impact Bumicerts Starts With Real Communities" — typography is doing the heavy lifting, no sub-navigation noise. Launch App / Menu in the corner.
02Three feature pillars and the start of "Choose Your Path". The split between Funders and Nature Stewards is clear without being heavy-handed.
03The "What exactly is a Bumicert?" accordion paired with a sample certificate card on the right. Good editorial pacing.
04Footer. Tagline ("Connecting communities with funders") in italic serif; quiet utility links on the right. No clutter.

2. Marketplace (/explore)

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").

05Marketplace top. Left rail is calm, the page heading uses the same serif accent as the landing.
06Card grid mid-scroll. "All filters" hint suggests there is a richer filter sheet behind the chips.

3. Sign-in flow — handle resolution

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.

07Sign-in modal. Email/Handle toggle, gentle backdrop blur over the marketplace.
08Handle tab. Username field + domain selector defaulting to .gainforest.id.
09Domain dropdown — including climateai.org. The pre-curated list is the right call for an alpha audience.
10Live preview "Signing in as satyam1.climateai.org" — small touch, removes ambiguity.

Finding 1 · The handle in the brief does not resolve

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:

11Inline error after the brief's handle fails to resolve. Clear, non-destructive — the form keeps everything I typed.
12Switched to satyam.climateai.org — the live "Signing in as …" confirms the resolved identity.

4. OAuth consent and return

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.

13climateai.org PDS sign-in. Identifier is pre-filled and locked — the user only types the password.
14OAuth consent screen with three scope rows. The plain-language scope summaries are excellent.

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.

15Onboarding modal — the only post-auth question. Editorial typography matches the rest of the app; "I'll decide later" is a humane escape hatch.
16Nature Steward landing — a "Set up your organization" form with website-based auto-generate, country, founding date, and a rich-text Tiptap editor for the description.

5. Create a Bumicert — the 5-step wizard

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.

17Create-bumicert hub: "Start a new application" plus "My Bumicerts" + "Pending Applications". Empty states are friendly ("You do not have any pending applications." with a confetti glyph).
18Step 1 (Cover Details). Cover image dropzone, 50-char title, default date range, work-type chips. Right-hand preview: "Please complete the first step to generate the preview."

Step 1 — Cover Details pass

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.

19Cover image uploaded. Preview pane on the right rendered immediately. Cover Details now ✓ in the stepper.

Step 2 — Impact Details pass

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…").

20Impact story written, links auto-detected (alpha.fund.gainforest.app rendered as a link).
21Short description below the rich-text. Counter shows 80 / 3000. "Generate short description with AI" button is right where you'd want it.

Step 3 — Site Details blocker

Three sub-sections: List of Contributors, Site Boundaries, Permissions. This is where the run came apart.

3a. Contributor search — finds Bsky users only

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

22"satyam" — five Bluesky .bsky.social / .net hits, but the logged-in satyam.climateai.org is missing.
23Searching the exact own-handle: "No results found." Hard to think of a worse demo moment for a non-Bsky AT-Proto user.

Finding 2 · Contributor typeahead misses non-Bsky AT-Proto users

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.

24Adding via DID: row renders as handle.invalid @handle.invalid until the page is reloaded.
25After a reload the same DID resolves correctly to satyam.climateai.org. Same row, same data, different render.

3b. Site Boundaries — the blocker

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

26Add Site modal. Three input modes — paste, upload, or draw on a Mapbox map. Visually clean.
27GeoJSON file (226 bytes) attached, name filled, Add primary CTA enabled.
28"Site added successfully" — synchronous green check. (The mutation did write to the PDS, see §6.)
29Back in the form: "No site found." Step 3 turns red. The site was created but is invisible to the wizard that needs it.

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

30After a hard reload of /bumicert/create/36: state is preserved, contributor row resolves correctly, but Site Boundaries is still empty.
31The dedicated "Sites" page (/upload/sites) — also "No sites yet". So this isn't a per-bumicert filter; it's the global site list that's empty.

3c. Review step rejects publish

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.

32Review step. Continue is disabled, Site Details says "missing". This is the floor through which the bumicert cannot be published.

6. PDS verification — what actually got written

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

7. Root-cause: indexer doesn't see this user's records

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:

Sourceapp.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:

Finding 3 · Indexer-coverage hole blocks the publish flow

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:

  1. Fix the optimistic cache update so just-created sites are immediately selectable in step 3, regardless of indexer lag.
  2. Fall back to 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.

8. Existing Bumicert detail view (read-side)

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.

33Description tab. Long-form prose renders cleanly, project image dominant on the left, badges + share button at top.
34Site Boundaries tab. Genuine empty state for this cert — pin glyph + "No sites linked to this bumicert".
35Donations tab. Indexed on-chain donor data. "Anonymous (0x0C62…9442) · 10 months ago · $279.83 USD".
36Leaderboard. "Impact Champions" page — All Time / Month / Week filter, "Include anonymous donors" toggle. $26,938.62 raised across 68 donors.

Summary — findings

1 · Indexer-coverage gap blocks site selection in the create wizard blocker

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.

2 · Contributor search excludes non-Bsky AT-Proto users UX

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.

3 · Just-added contributor renders as handle.invalid until reload UX

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

4 · The handle in the brief does not resolve env

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.

5 · Marketing & marketplace are excellent good

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.

6 · Sign-in flow is the strongest AT-Proto UX I have used good

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.

7 · Wizard layout is genuinely a pleasure good

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.

8 · Site write-side & PDS storage work correctly good

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.


Reproducing

Date2026-04-29 Toolagent-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)

Full session recording

~19 min, no audio. Compressed VP9 1280px-wide. Original 8.9 MB → 6.7 MB.

Verifying the records on the PDS yourself

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

Reproducing the indexer-coverage gap

# 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