Commit Graph

417 Commits

Author SHA1 Message Date
martbost 9249ae6368 Member milestones: a celebration photo + caption to the proof channel, team feed and shared payments topic when the contract reaches 1,000 members (then 2,500 / 5,000 / 10,000), once per milestone; sendPhoto capability; admin preview route
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 04:50:56 -05:00
martbost d72248bacb SafePal is the preferred wallet: listed first with a Recommended tag in the picker, detected by flag/window.safepal, named first in the in-app notice, join error, chatbot answer and AI prompt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 08:45:14 -05:00
martbost c3523bdac2 Webinar flyer for Tuesday, September 22 (event pop-up)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 06:40:08 -05:00
martbost f28804862b Training: two NAS zones (above the first video, above the Circle Method); CSP allows the NAS script and https images
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 16:41:25 -05:00
martbost c6e6acbdc5 PolHunter embed on /training, CSP allows polhunter.com
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:12:25 -05:00
martbost 9ad864c0ba join-click and admin: finish removing the queue calls (an idempotency guard skipped two edits)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:30:58 -05:00
martbost 7c8a43a055 Delete the rotation queue; sponsors.json stays as the member directory
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>
2026-09-19 07:26:25 -05:00
martbost 4981b1a923 Retire the rotation queue's voice; team links pick the nearest open spot in their own leg
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>
2026-09-19 06:50:32 -05:00
martbost 97ddea7e1e Training video: how your link places people
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>
2026-09-18 16:35:52 -05:00
martbost b88218b1fb Add a Presentation item to the site nav
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>
2026-09-18 16:21:17 -05:00
martbost 838e5bb2d0 Only the position's owner sees the placement switch
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>
2026-09-18 15:49:44 -05:00
martbost da223c288b Remove the last three-state references from the spec
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 15:35:24 -05:00
martbost f99a777b71 Spec now describes what shipped: two settings, no leg locking
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>
2026-09-18 15:35:02 -05:00
martbost 07ab4cec79 Members choose how their link places people
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>
2026-09-18 13:54:14 -05:00
martbost 80461a948d Spec: member-selectable placement setting, with sponsor inheritance
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>
2026-09-18 13:42:33 -05:00
martbost 5c34ab3305 Remove the duplicate-hunt instrumentation, keep the audit line
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>
2026-09-18 12:50:28 -05:00
martbost 5b2dcb943a Post each on-chain event once per feed, permanently
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>
2026-09-18 12:06:48 -05:00
martbost 096c12ab46 Fix scope bug that dropped the referral from the join button
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>
2026-09-18 11:30:30 -05:00
martbost 8987451cf6 Carry the placement choice with the referral too
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>
2026-09-18 11:25:34 -05:00
martbost 2b36a29a68 Stop the invite being lost when a prospect clicks through the nav
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>
2026-09-18 11:18:23 -05:00
martbost 25b3636352 Presentation page: drop 'at full participation'
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>
2026-09-18 11:04:36 -05:00
martbost 7f7732b441 Log the Telegram message_id we get back, to settle which copy is ours
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>
2026-09-18 10:55:56 -05:00
martbost 90189aab6c Presentation page: drop the 'most people earn little or nothing' clause
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>
2026-09-18 10:31:47 -05:00
martbost d85999535d Presentation page: lead the risk paragraph with 'There is no company'
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>
2026-09-18 10:28:34 -05:00
martbost 653f4f94be Standalone presentation page: the video, subtitles, nothing else
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>
2026-09-18 10:12:20 -05:00
martbost f18ef592fa Make announce-once survive a deploy, not just a restart
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>
2026-09-18 10:02:14 -05:00
martbost 812cbd03b8 Log every Telegram send with a per-process id while tracing duplicate payment lines
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>
2026-09-18 09:55:51 -05:00
martbost e43b812f97 Refuse to post the same payment line twice, at the sender
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>
2026-09-18 09:37:10 -05:00
martbost 85a6056354 Announce each payout once, the same way registrations and upgrades already are
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>
2026-09-18 09:31:56 -05:00
martbost 1284d28891 Keep a member's link inside their own team, and stop the dashboard contradicting the admin
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>
2026-09-18 09:08:24 -05:00
martbost 2f4b70c886 Members can take it back off, and OPTIONAL is now impossible to miss
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>
2026-09-17 06:52:43 -05:00
martbost 88a3ad1149 Captions: add German and Portuguese, make them findable, and fix the dead "Add mine" button
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>
2026-09-17 06:39:08 -05:00
martbost 1c51599f07 Captions: Manson's five videos, in Italian, French and Spanish
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>
2026-09-17 06:20:46 -05:00
martbost b3385ca3f8 Captions: translated subtitles for the training videos, no re-render, no re-voicing
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>
2026-09-17 06:06:34 -05:00
martbost 8bdc105a7a Live visitor smoke check, and the written explanation for Manson
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>
2026-09-17 04:53:49 -05:00
martbost 91a6893df9 Member profiles are optional: an invitation on the dashboard, never a gate
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>
2026-09-17 04:44:09 -05:00
martbost 0189278aa5 QA: end-to-end join-flow harness proving the post-registration sign-in, including the refuse regression
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>
2026-09-16 19:05:04 -05:00
martbost 49a0778a53 Post-join sign-in actually works: live position lookup for a cold index, ordered calls, redirect no longer races the signature
#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>
2026-09-16 18:49:33 -05:00
martbost 6f1c9ff8ca New members are signed in to their inbox right after registering, so the profile gate actually fires
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>
2026-09-16 18:11:12 -05:00
martbost 2f90e1a97f QA pass on the member profile gate: stable button ids, completion opens the dashboard tab, local devCode, 47-test E2E suite
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>
2026-09-16 17:40:03 -05:00
martbost 2c5fff20f5 Members can change their username and email: in-place profile card replaces the separate alert-email opt-in for signed-in owners
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>
2026-09-16 17:28:26 -05:00
martbost 1ea8f8df48 Profile gate fires only on the member's own dashboard, not when they browse a teammate's page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:18:15 -05:00
martbost dd74ded287 Profile read endpoint answers 200 signedIn:false for visitors, so a shared /my link logs no console error
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:05:05 -05:00
martbost 7d07fc01f1 Required member profile: username + verified email per position, gated at proof of ownership
Marty, 2026-09-16: leaders can write but 94% of positions cannot receive (47 of 771 have ever signed
in to messaging, 482 messages sit 85% unread). profiles.js stores username + verified email per
POSITION (one wallet holds one position, so a Triple Play holder has three; the person is the email
and one email may hold several positions). Seeds the 40 emails already on file from
member-alerts.json, pre-filled but unverified so confirming costs one tap.

Writes are only ever accepted from a session that PROVED ownership: wallet personal_sign
(messages.verifyChallenge) or the Telegram Mini App bridge. The public /my/<id> page is untouched and
cannot write a profile, verified by test: all four endpoints 401 unauthenticated while /my/21 stays
200. Endpoints GET /api/public/profile, POST .../username, .../email-start, .../email-verify, plus
GET /api/admin/profiles for coverage. Email codes: 6 digits, 15 min, 60s cooldown, 5/day, 6 tries.

profile-gate.js is a two-step modal that cannot be dismissed, fired on dashboard boot (covers the
Mini App landing) and right after a wallet sign-in. Chatbot canned answer + AI prompt updated.
28 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:56:35 -05:00
martbost 8521f7a3f2 Triple Play: highlight the Corona catch (Gen 8 Corona buys pay your Gen 1, which is you) on how-pay-works, tools objection bank, chatbot canned + AI prompt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:46:11 -05:00
martbost 8da1c144ea Marty is a team leader, not a program founder: fix the #21 upgrade-tip wording on how-pay-works and in the chatbot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:44:11 -05:00
martbost c89bbb5c59 how-pay-works: keep level and amount cells on one line in the level-scale table
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:43:41 -05:00
martbost fedbb7f0f7 how-pay-works: depth-ladder diagram shows all 7 paying layers; Premium/Standard table lists all 7 upgrades; level-scale table adds a green net-after-upgrade-price column
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:40:29 -05:00
martbost c371149507 Chatbot: canned + AI-prompt answer for reading the generation table (level you hold vs level they buy; Vertex row is gen 7, not a total)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:35:57 -05:00
martbost 19127a11a1 how-pay-works: generation table shows the level you hold AND the level each generation buys (Clinton's off-by-one reading), plus running-total note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:34:42 -05:00