The cancellation card could only say two lines, an eyebrow and a date label,
because the card has no prose field and the API never passed one. A notice needs
to say what happened and what comes next, so it now carries a body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Today's webinar was cancelled and the card had no way to say so: it required a
flyer to render at all, and it always drew a Join Google Meet button and the
calendar buttons, so the only shape it could take was an invitation.
It now renders without an image, and with no meetUrl it draws no Join button and
no calendar row. Never invite someone to a meeting that is not happening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same artwork and the same time list, which is still correct: the event keeps its
19:30 UTC start and no zone offset moves between the 22nd and the 24th (US DST
runs to 1 November, EU to 25 October). Only the date headline is redrawn, with
the background read from the rows either side of the old ink so the table glow
keeps its falloff.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The daily snapshot outgrew Telegram. Past 1,000 members the post hit the
4,096-character limit and was rejected outright two mornings running, and
capping the lists to fit meant choosing what to throw away. Manson caught what
that cost: the level-up list was ordered by time, so the cap hid two Culmen and
thirteen Fabrica behind two dozen routine Ascensus upgrades.
/day/<date> now carries every climb, every payment with its Polygonscan link,
every new position and every recruiter, with no cap on any of it. Company-wide,
no team filter, keyed to the Central calendar day so a link posted on Monday
still shows Monday next year. Today is computed live; a finished day is written
to disk once, because the chain index only keeps a few hundred payouts and an
older day could never be rebuilt from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The printed times contradicted the event's own start. Eastern and Central were
right, but Pacific was given as 1:30 PM when 12:30 PM is correct, Mountain was
missing entirely, Kenya sat an hour early beside South Africa, and Europe sat
three hours early beside Ghana. The block is redrawn as one zone per line, in
the flyer's own typeface, on the original artwork. New filename so nobody is
served the old one from cache on the day.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a phone the Google Calendar app claims calendar.google.com links and opens
its own home screen, dropping the event. The pop-up now offers one "Add to
Calendar" button that goes to the calendar file on iOS/Android (served inline
so the phone hands it to its calendar) and to the Google event editor on
desktop, with the other route as the second button either way. The .ics is
also folded to RFC 5545 line lengths so Outlook accepts it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marty (2026-09-19): delete it if it no longer serves a purpose. The public link has run on the
chain walk since the mode flip and the queue's active head never received a join. Gone:
active/waiting/qualified statuses, normalizeStatuses, activeSponsor, publicSponsorPayload, the
increment/activate/qualify/reset actions, the click counter, the auto-count/auto-advance/
level-sync block, the legacy queue branches in current-sponsor and the submit-id classifier,
the chatbot's "placements waiting" facts, and the queue columns and buttons in admin. What stays
is a member directory (name, parent, level, notes, contact email) because the payout alerts and
the feed's name map read it. The chain walk is the only company rotation now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The queue's AUTO-ADVANCE post announced a rotation event this morning when a member had
built his own leg (the Triple Play). The queue stopped placing anyone when the public link
moved to the chain walk; what was left of it was three Telegram messages, a classifier that
called any join under a former queue member a "rotation join" and swept the newcomer into
the queue, and a preference inside the team-link picker for queue positions over the nearest
open one (15 members affected).
Verified offline over the full snapshot first: all 889 personal links already target the
member or a position inside their own subtree; the walk starts at the member's own children
and cannot leave the leg. Now: the three messages are gone (counters still update silently);
a join is a rotation join only if the company link could have produced it (the company link
always offers the shallowest open position, so a deeper referrer rules it out; 'invite-<id>'
submissions are leg joins outright); nothing is auto-added to the queue; and the team link
picks the nearest open position in the member's own leg, in tree order.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
94 seconds on the two placement settings that shipped today, in Marty's
voice, on /training as a new card.
Covers what members will actually ask: what each setting does, that Team
rotation means their OWN downline and never the company rotation, that a
new member starts on their sponsor's setting so a team runs its leader's
play by default, that one click changes it and nobody is locked in, and
that changing it only affects new joins because placements are permanent
on-chain.
Ends on where to find it and the standard risk disclaimer.
Project: video-placement (tts.cjs -> build.py -> HyperFrames). Audio
loudness-normalised to a -18.8 dB mean, which is the target the earlier
videos landed on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One click to the full team-build walkthrough from anywhere on the site.
Sits next to Training on the prospect-facing pages.
Deliberately NOT added in two places: the presentation page itself keeps
no nav at all, so a viewer is never offered a join route that bypasses
whoever invited them, and the invite page keeps its existing nav for the
same reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marty asked where to find the setting, which surfaced the problem: /my/<id>
is a public page and the switch was rendering on it for every visitor. The
POST is wallet-gated so nobody could actually change someone else's
placement, but offering a stranger a control over another member's team is
wrong on its face.
It now stays hidden until /api/public/msg-me confirms the viewer has proved
with their wallet that the position is theirs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marty's decisions, 18 September: two states rather than three, and no
ability for a leader to lock their leg. Also records the fix behind
Terry's original complaint — a personal link no longer falls through to
the company rotation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Terry asked how a referral of his would be set up as "Direct with
spillover", and whether his team could follow suit. Two settings, on the
member's own dashboard:
Team rotation — the join goes to the next position IN THEIR OWN TEAM
that still needs its 2
Direct — every join lands directly under them, as depth
A new position starts on whatever its SPONSOR was using at the moment it
joined, so a team duplicates its leader by default. That is a one-time
copy, not a live link: a sponsor changing their mind later never moves
anyone already in their team, and an explicit choice is never overwritten
from above. The panel says where an inherited setting came from, so nobody
discovers months later that their link was doing something they did not
pick.
Also closed the hole behind Terry's original complaint: a member's
personal link no longer falls through to the COMPANY rotation when their
leg is fully qualified. Team rotation means rotation inside their own
downline and nothing else — handing someone's referral to a position they
have never met was never the intent, and it is what offered Terry's
prospects #148.
The five positions already on directDefaultIds (21, 137, 139, 30, 840)
read as explicit Direct, so nothing changes for them. Everyone else stays
on Team rotation, which is what they have today.
qa/placement.mjs, 17 checks: defaults, choosing, inheritance from the
sponsor, explicit choices surviving a re-run, a sponsor switching not
moving their existing team, and persistence across a restart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Terry asked how a new referral of his would be set up as 'Direct with
spillover', and Marty asked whether a leader's choice should carry down to
their team. This writes up the answer for the three of them to agree on
before anything is built.
Three states — Team first, Direct, Follow my sponsor — with a new member
starting on whatever their sponsor uses, so a team duplicates its leader
by default without anyone being locked in.
The rule that needed pinning down: inherit from the SPONSOR, never the
matrix parent. Under spillover those are frequently different people, and
the setting should follow whoever recruited and teaches them rather than
whoever they happened to land beneath.
Also names the pitfalls rather than leaving them to be discovered — chiefly
that inherited Direct is a doctrine change, not just a setting, since it
reverses the team-first play for everyone below a leader who picks it.
Nothing built. Three open questions at the end need Marty's call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The verbose per-send logging did its job: it proved this process sends
each payout once, which is what pointed the search away from the deployed
app and at a second server running elsewhere with the same bot token.
Dropping the text dump. Keeping one line per send with the message_id
Telegram assigns — that is the line that made the second sender findable,
and a payment feed is worth being able to audit.
Confirmed fixed: the payout at 17:47 posted exactly once to each feed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marty is still seeing duplicate payment lines, and the instrumented log
finally showed enough to act on rather than keep hunting.
Two things were wrong.
The dedupe I added earlier keyed on message TEXT within 15 minutes, which
fails in both directions. The log caught it swallowing a legitimate post:
two different registrations generate identical "the team just grew" copy,
so the second one was suppressed as a duplicate. Meanwhile anything
carrying a different transaction link sails straight past it.
And it only ever lived in memory, so it could not help across a restart or
across the moment during a deploy when two containers are both alive and
both tailing the chain.
The key is now the on-chain event itself — type, transaction, the member
ids, the amount — recorded per destination chat in a file on the volume,
written before the request goes out. Same event, same feed, one post, no
matter how many times anything calls it or how many processes are running.
Distinct events are never confused, because the transaction hash is in the
key.
All four feeds carry it: team-build topic, recruit topic, proof channel,
shared payments topic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The placement flag was declared inside the invited-visitor branch but read
further down when building the join button href, so it threw a
ReferenceError there. The display was right — the page named the inviter
correctly — but the button fell back to a bare /join-now, which is the one
place that actually matters.
Caught by walking the live paths rather than trusting the earlier pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auditing the rest of the prospect paths after Terry's leak. Every entry
point funnels through /join/<id> — the Telegram Mini App redirects there,
member page-builder pages link there, the public /my/<id> share panel
links there — so the cookie covers all of them.
One gap left: ?direct=1 is the inviter's explicit decision to take the
join under themselves rather than route it down their leg, and only the
id was being remembered. A prospect who opened a direct link and then
wandered through the nav came back through the moving link instead — the
opposite of what the shared link asked for.
The flag now rides along in its own cookie, and both /start and /join-now
prefer an explicit choice over the position's default.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Terry hit this: he shares /join/840, his prospect taps "Training" in the
nav, and by the time they reach /start the site has forgotten him and
offers the COMPANY rotation position — #148. His referral, handed to a
stranger, because the prospect read the training first.
The invite now outlives the page it landed on:
- Serving /join/<id> sets an rmc_ref cookie (30 days, Lax).
- /start reads it (or an explicit ?ref=) and shows the INVITER instead of
the company rotation, routed by exactly the same rules the inviter's own
page uses — direct placement if their position is set that way, else the
next open spot in their leg. The two pages can no longer disagree.
- The Join button carries ?ref through to /join-now, which already knew how
to place a ref correctly but was never being given one from here.
- /join-now also falls back to the cookie when it arrives with no ?ref.
The page now says whose team it is ("Invited by Member #840") rather than
an anonymous "current placement", and when the inviter is already
qualified it says plainly that the entry fills the next open spot in their
team.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson's third note, and a standing preference: the phrase reads as a
caveat about falling short rather than a description of the mechanism.
The sentence still says the numbers describe how the contract works
mechanically, and the next one still says they are not a projection, a
guarantee, or a typical result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RM Circle has one indexer, one sender, one proof-channel send site, and the
instrumented log shows it sending each payout exactly once. Yet duplicates
keep appearing in the channel, so a second sender with the same bot token
is posting the other copy.
Recording the message_id Telegram assigns makes the next occurrence
decisive: if both visible copies carry ids this process logged, it sent
twice; if only one does, the other sender is external and the id gap says
roughly when it posted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson's second note. The sentence now reads 'Results depend entirely on
your own effort, your timing, and factors outside anyone's control.'
The paragraph still opens with 'This is not an income promise' and still
says the figures are not a projection, a guarantee, or a typical result.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson's note on the page. Dropping the opening sentence about buying
positions with crypto and leading with the no-company line puts the
strongest point first: nobody is holding the money. Everything that
sentence carried is still there — Polygon, volatility, irreversibility —
just folded into the sentences that follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marty needs a link he can send that shows the full team-build presentation
without steering anyone toward a sponsor. So the page carries no header,
no navigation and no join button — whoever shared the link is the person
the viewer goes back to, and a single line under the player says exactly
that.
The player fills the first screen and the subtitle chips sit directly
under it, same as the training videos: English, Italian, French, Spanish,
German and Portuguese, off by default for an English reader and switched
on automatically for anyone who has already picked a language with the
globe button.
The disclaimers are complete but deliberately below the fold, per Marty's
ask — income language, crypto risk, irreversibility, no-advice — reachable
by scrolling rather than sitting on top of the video.
Served at /presentation and /webinar so either link works. noindex, since
it is a link to hand out rather than a page to rank.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Instrumenting every send found the real shape of this: the live process
sends each payout exactly once, to each feed, with no duplicate to
suppress. So the second copy members were seeing never came from the
running container — it came from ANOTHER one.
During a deploy the outgoing container and the incoming one are both alive
for a moment, and both tail the chain. The announce-once record was held
in memory, so each had its own copy and each announced the same payout,
about a poll interval apart. That matches exactly what the proof channel
showed: identical lines a minute apart, and more of them today because I
deployed four times in half an hour.
The record now lives in its own small file, read fresh and written
atomically on every announcement, so whichever process gets there first is
visible to the other. It also closes the original hole, where the
announcement went out before the state recording it was flushed at the end
of a tick.
Events are a few an hour, so a small read and write per event costs
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Members are seeing payment receipts twice. This process has a durable
announce-once per on-chain event and a send-level dedupe, and neither is
firing, which says the second copy is not originating here. Rather than
keep reasoning about it, log every send with a random per-process id so
the log answers it directly: two ids means a second instance is posting,
one id twice means the dedupe key differs.
Temporary. Comes out once the source is confirmed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Members were seeing the same payout announced twice in the proof channel
and the shared payments topic, and asking whether someone had been paid
twice. The money was never wrong — one transfer, one contract event — but
a payment feed is the last place where a second receipt is acceptable,
because it makes people doubt the ledger.
The indexer's durable announce-once key is the real fix and shipped in
85a6056. This adds the layer behind it: sendTelegramTo refuses to post the
same text to the same chat within 15 minutes, and logs when it does.
Distinct events can never collide — every line carries its own member ids,
amount and transaction hash.
Deliberately at the sender rather than the emitter, so it holds no matter
which upstream path replays an event.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Marty saw #49's 4,971.22 POL Gen-4 pass-up from #222 posted twice in the
payments feed. The money is fine: the transaction has one transfer log to
#49's address and one contract payout event, 4971.219256933542 POL, paid
once. Only the announcement doubled.
Payouts were the one event type without the durable announce-once guard.
registered and upgraded are keyed into state.announced — 4,000 entries,
persisted with the rest of the index state. Payouts instead leaned on
state.payouts, which is a rolling window trimmed to KEEP_PAYOUTS (400),
and the announcement was emitted BEFORE that state was written. So a
restart in the gap between announcing and persisting, or any re-read of
the same block, announced the payment again — while an upgrade in the
very same transaction was correctly suppressed. That asymmetry is exactly
what Marty reported: the payout line twice, #222's Apex upgrade once.
The existing state.payouts check stays as the first line of defence; this
adds the durable one behind it, keyed on tx+logIndex, which identifies the
on-chain event exactly.
Not caused by today's deploy — that container started at 14:10 UTC and the
payout landed at 13:40 UTC. Checked before blaming it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two faults found chasing why Terry (#840) was routing joins to #148, a
position outside his team.
The leg lookup only considered positions on the curated rotation list.
Terry qualified the same day he joined, so his two directs (#843, #844)
were too new to be on it. With no leg match the code fell straight through
to the GLOBAL company rotation — sending his people to a stranger while
his own directs sat on 0/2. That is the opposite of the team-first
doctrine the moving link exists to serve. There is now a last-resort step
inside the leg: the nearest position still needing its 2, curated or not.
The curated order still wins when it applies; the global rotation is now
only reached when the member's whole leg really is qualified.
Second, the dashboard ignored directDefault entirely. Marty had already
set Terry to direct placement in the admin, which the join page honours —
but Terry's own share panel still told him his link routed to someone
else. Two different answers to "where do my people land". The panel now
says rotation is off and every join lands under him, and explains
?direct=0 for the exception.
Also made the all-qualified case name what it is: joins go to the company
rotation, placed wherever the company is filling, not inside your leg.
That wording is what made #148 look like a bug rather than a fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things, one of which we were quietly getting wrong.
REAL OPT-OUT. We told members "removable any time" in three separate places and there
was no way to remove anything. Same class of failure as the dead "Add mine" button:
copy written, mechanism never built. The profile card now offers Remove username,
Remove email and Remove everything, and profiles.remove() clears the value while never
touching the position. Adding it again later works exactly as before, so opting out is
not a one-way door.
It is a two-step inline confirm, not a native confirm() dialog. Browsers with "suppress
dialogs" switched on return false, which would have made Remove look broken in precisely
the way Add mine was broken. First tap arms and explains the consequence, second tap
does it, and it disarms itself after six seconds.
THE PROMISE WE WERE BREAKING. The payout mailer and the upgrade alerts read
member-alerts.json, NOT profiles.json. So a member who completed the new profile got
NOTHING, while the invitation card promised "a note the moment POL lands in your
wallet". Verifying a profile email now mirrors into member-alerts.json so every existing
alert path works, including the unsubscribe link, and removing the email clears both
stores so opting out actually stops the email.
MANSON'S HUGE ASTERISK. He asked for it to be bigger and bolder so nobody can say they
did not see it, and on a decentralized build that burden is ours, not the member's. One
gold badge now appears on the dashboard invitation, inside the dialog on every step, in
the inbox banner and on the profile card itself: "100% OPTIONAL - never required", with
the plain statement that the position, the payouts, the team and everything on the page
work exactly the same without it, nothing on chain depends on it, and it can be removed
again any time.
gate-e2e is 52 assertions, up from 38. The new ones prove one tap does NOT remove
anything, the second tap does, the server agrees the value is gone, an email-only
removal leaves the username alone, and the invitation reappears afterwards so the whole
thing is reversible. profiles-unit 28, signin-fallback 7, captions-e2e 158 all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things.
GERMAN AND PORTUGUESE. Marty's call, and the right one: the translation cache only
shows who is already here, not who Manson is bringing. Manson knows his audience, so
Portuguese goes in even though site data ranks German above it. All five videos now
carry English, Italian, French, Spanish, German and Portuguese. Disclaimers checked by
hand in the two new languages: "Es wird kein Einkommen garantiert" and "Nenhuma renda
e garantida".
FINDABILITY. Marty went looking for the multilingual feature on his own training page
and could not find it, because the first build leaned on the native CC control and
nothing switches on for an English reader. A feature nobody can find is not shipped.
Every captioned player now carries a visible row of subtitle chips underneath, named
in their own language, with an Off option. Captions still stay off by default for an
English reader and still turn themselves on for anyone who already picked a language
with the globe button; the chips just make the choice visible instead of buried.
THE DEAD BUTTON. The inbox banner's "Add mine" shipped with NO click handler, so it
rendered and did absolutely nothing. That is why Marty could not add his address while
connected to the right wallet: nothing was listening. It now opens the same optional
profile dialog and re-renders when it completes.
Tests: gate-e2e 38 (up from 33, and rendering that button is explicitly not the test
any more, it has to actually open the dialog), captions-e2e 158 across six languages
including the new chip behaviour, clicking one and turning them back off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson narrowed the ask to five: how the team build works, joining on the site,
joining in the dApp, your level is your reach, and the textbook play. Those five now
carry English, Italian, French and Spanish caption tracks. The other fifteen training
videos are deliberately untouched.
Languages are the top three by real demand from the translation cache rather than by
instinct. German still outranks Portuguese there by more than double, which is worth
settling before adding a fourth.
Captions stay off for an English reader and switch on automatically for anyone who
already picked a language with the globe button, so this rides the choice members
have made rather than adding a second one.
Disclaimer language came through intact in all three, checked by hand because a
softened "no income is guaranteed" is a compliance problem rather than a typo:
Nessun reddito e garantito / Aucun revenu n'est garanti / No se garantizan ingresos.
A native-speaker read of those specific lines is still worth having before this is
promoted anywhere.
qa/captions-e2e.mjs now discovers the captioned players from training.html instead of
a hardcoded list, so it cannot drift as videos are added, and it reads the parsed cues
back out of each player: 91 assertions covering content type, cue counts, the right
track showing per language, English never showing alongside, and a guard against a
track that is really English wearing a foreign label.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson asked whether the training videos could be in other languages. A real dub
means re-rendering every video per language: translated Romance-language speech runs
15-25% longer than English and these are slide videos with fixed beat timings, so
swapped audio drifts off what is on screen. That turns 15 files into 75 and makes
every future lesson edit a five-way job. Captions keep ONE video and add small text
tracks beside it, so editing a lesson re-captions that lesson only.
tools/captions.mjs: ffmpeg pulls the audio, ElevenLabs Scribe transcribes with word
timings (the same STT we already use to verify voiceovers), words are grouped into
SENTENCES, and only then translated. Translating cue-by-cue is why most auto-captions
read badly at cue boundaries. Translation goes through our own /api/public/translate,
so every phrase caches forever in translations.json and costs once across the site.
The STT response is cached on disk because it costs money; never pay for it twice.
Language choice is evidence, not instinct. The translation cache shows real member
demand: Italian and French far ahead, then Spanish, then GERMAN - which beats
Portuguese by more than double, the opposite of what we assumed. Proof of concept is
Italian on the 5-minute overview.
Captions are deliberately NOT on by default. An English reader does not want them
forced over the picture; someone who already switched the site to Italian almost
certainly does. public/vtt-lang.js shows the track matching their 🌐 choice and
leaves the player's CC button to do the rest.
Two bugs this caught in my own code, both found by reading the output:
- the line wrapper truncated each cue to two lines and SILENTLY DELETED the overflow,
so "the whole plan fits in one sentence" shipped as "fits in" then "sentence". It
now chunks by the real wrapped line count and never drops a word.
- a one or two word tail ("sentence." alone on screen) folds back into the previous
cue.
The suite asserts all 780 transcript words survive into the English track.
Also: .vtt had no Content-Type mapping, so it served as octet-stream and browsers
silently ignore such a track. qa/captions-e2e.mjs (12 assertions) reads the parsed
cues back out of the player rather than trusting the markup, which is the only way to
catch that class of failure. profiles-unit 28 and gate-e2e 33 still green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qa/live-visitor.mjs is read-only against production: a visitor on any ID-keyed
shared link must see no modal, no invitation, no 401 and no page errors. It is the
check that the optional-profile change did not disturb anyone who is not signed in.
Passing 9/9 against rmcircle.team.
docs/for-manson-optional-profiles.md is the note to send him before anything goes
to the team, since he asked to see it first. It leads with his objection being
right, states plainly that nothing is required, and is honest about the trade: we
accept lower coverage rather than mandate identity. It also covers the level-8
question, where the pay rules were already fine and only the ladder graphic was
misleading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.
Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.
The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.
For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.
Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.
QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.
qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
qa/harness-server.js boots the real server with chain reads stubbed (COLD=1 reproduces the cached-index
state that left #787 without a profile). qa/join-flow-e2e.mjs drives the real /join-now page with a fake
wallet producing genuine secp256k1 signatures.
Three scenarios pass: signs on a cold index (9) - the gate appears on their own dashboard; refuses to
sign (10) - REGRESSION, the join still completes and redirects with the page usable; signs on a warm
index (9). Full set green: profiles-unit 28, signin-fallback 7, gate-e2e 47, join-flow 9/10/9, plus the
live shared-link check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#787 registered at 18:35 CT, 22 minutes after the first join-flow fix, and still had no profile. Two
causes, both fixed:
1. messages.verifyChallenge resolved the wallet through chain.memberIdByAccount, which reads the
CACHED index. Seconds after a registration that wallet is not in it, so the signature was rejected
with "No RM Circle position is registered to this wallet". It now accepts an idHint (the position
id from the member's own registration receipt) and, on a cache miss, reads that id live from the
contract via chain.verifyMember, minting only when the contract says this exact wallet owns it.
That is a stronger proof than the cache, not a weaker one. Now async; the single call site awaits.
2. join-now.js fired the sign-in and a 4.5s redirect in parallel, so the page could navigate away
while the wallet was still showing the signature prompt, and it did not wait for submit-id (which
runs the live verifyMember server-side that seeds the index). It now awaits the report, passes the
receipt id, and redirects only once the signature settles, with a 120s bailout.
qa/signin-fallback.mjs (7 assertions) proves the cold-index path with real secp256k1 signatures and
covers the abuse cases: a hint for a position the wallet does not own is refused, and a signature from
another wallet is refused. Existing suites still pass: profiles-unit 28, gate-e2e 47.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by checking the live data: 59 positions registered in the 36h to 2026-09-16 and not one has a
profile row, including #785 which registered 20 minutes after the gate deployed. The join flow
redirects to /my/<newId> but the member arrives with no inbox session, so the gate never runs and the
leader still cannot reach them. join-now.js now takes the one free personal_sign immediately after the
registration confirms, while the wallet is still connected, which is the cheapest moment to ask.
Best-effort: declining does not block the join, and they are asked again on their next sign-in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Findings fixed: the gate's buttons had no stable ids (fragile to test and maintain), and finishing the
gate left the member on the pitch tab where the profile card and Messages are not visible, so
completion now opens the Position Dashboard tab. /api/public/profile/email-start returns devCode
outside production so the flow is testable locally, matching the InstantAdPay pattern.
qa/profiles-unit.mjs (28 assertions) and qa/gate-e2e.mjs (47 assertions, real sessions, real UI) with
a README. All pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first-time gate cannot be dismissed; editing later can. The 'Email me my alerts' card becomes
'Your member profile' for a signed-in owner, showing the username and confirmed email with Change
buttons, so the verified address is the one source of truth for both messaging and payout alerts.
Visitors and non-owners keep the original opt-in form unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>