Commit Graph

212 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 cbfc642187 Upgrade promo overlay: flyer shown once per session on the dashboard and Mini App (announce.js promo mode, admin-configurable promo* keys) 2026-09-16 04:22:53 -05:00
martbost 4b982dffe0 Team-build topic: telegramTeamEvents=none leaves only the daily snapshot
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 07:50:20 -05:00
martbost 5cbd47db27 Team-build topic: config switch to drop payout lines (telegramTeamEvents=no-payouts)
Payout notifications migrate to the shared payments topic; registrations,
upgrades and the daily snapshot keep posting to the team-build topic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 05:28:26 -05:00
martbost 876b6eebd8 Echo payout proof lines into a shared cross-program Telegram topic
New config keys telegramEchoTopicId / telegramEchoChatId / telegramEchoEvents
(default: payouts). Each echoed line is prefixed with the program name because
InstantAdPay posts into the same topic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:50:36 -05:00
martbost 13eabc6b9a Payment proof card points to the public RM Circle Proof channel (t.me/rmcircleproof)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 05:11:25 -05:00
martbost 996c5b2a69 Show the Telegram payment channel in the live payment proof section (home + join)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 04:57:13 -05:00
martbost b9c079fa3e Route /announce.ics through the API handler
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:04:41 -05:00
martbost 359bfee3db Event pop-up: Add-to-calendar buttons (Google Calendar link + .ics for Apple/Outlook)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 17:02:07 -05:00
martbost 6c2306966d Proof channel: one compact line per event (payouts + upgrades), HTML link, no hashtags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 09:46:56 -05:00
martbost 617e519a3b Proof channel posts link to the team site, not a personal link
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 08:54:50 -05:00
martbost 42a6caacb0 Telegram: optional company payment-proof channel feed (same bot, separate chat)
config.telegramProofChatId (+ optional telegramProofTopicId, telegramProofEvents
'payouts'|'all') mirrors payout events with the recruiting copy plus the
Polygonscan receipt. Inert until the chat id is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 07:03:23 -05:00
martbost 946a9d75b5 Gate consistency video to members; embed on rhythm page; add vertical cut
- Members-only: the stay-consistent video now requires a wallet-verified session
  (server 403 + training-page lock UI), matching Module 3.
- Embedded on /weekly-rhythm above the sheet (hidden in print) with a members-only
  fallback card for signed-out visitors.
- Added a ~31s vertical (1080x1920) social cut, ungated, for Telegram/social.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 06:08:46 -05:00
martbost e3f6b00f95 Make event pop-up admin-toggleable; drop it from join pages
The Weekly Huddle overlay now reads its content from /api/announce (config-
driven) instead of a baked-in block, and Admin -> Settings -> Event pop-up
exposes a toggle plus editable fields (eyebrow, flyer, join URL, date, times,
auto-hide, event id). Defaults are seeded into config on boot so the panel
reflects real state. Removed the include from join.html so it never interrupts
the join flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 05:28:03 -05:00
martbost 44651a92b7 Textbook Play copy: Marty is a top team builder, not a program founder
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 15:11:33 -05:00
martbost 106f0fa7bd Training: 'The Textbook Play' coaching video — a real position run by the book
111s in Marty's voice: position #21's ledger, catch-funded climbs, the
pass-over cost, this week's Apex catch-and-climb, and the dashboard/sponsor
next-play close. Chatbot + AI prompt synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 14:54:06 -05:00
martbost 79470e471c Members area: weekly webinar replay page at /replays
Wallet-gated like the Method lessons: the API returns titles/dates to
everyone (prospects see the shape of the product) and video URLs only with a
member session. List lives in the data volume (replays.json) so the weekly
add never needs a deploy; videos stream from the DO Spaces bucket (CSP
media-src added). Teaser on /training; chatbot + AI prompt updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 06:46:06 -05:00
martbost 8ea371dd09 Telegram team posts: disable link previews on payout/team-build notifications
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 05:12:40 -05:00
martbost 747977b368 Catch eligibility: catcher needs level >= the level the buyer is LEAVING, not the level being bought
Matches _payUpline (level > levelIndex, levelIndex = buyer.level-1). The old
depth+1 rule told #21 (Culmen) it could not catch #49's Apex buy; #21 is #49's
4th matrix upline and does catch it. Fixed in the owner alert, coaching scan,
dashboard pipeline, AI prompt, leader digest, chatbot and how-pay-works copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:51:30 -05:00
martbost 2d1ccadfd9 Suite: let members switch between positions they hold
A member holding #2 and #3 connected #3's wallet, closed the browser, came
back, and still saw #2. Not a wallet problem: the sign-in cookie lasts 30 days
and there was no sign-out anywhere on the site, so the first position he
authenticated as was pinned to that browser and the wallet was never consulted
again.

This hits precisely the people we tell to buy several positions - the Triple
Play is on /how-pay-works and in the chatbot - so it will keep happening.

Adds POST /api/public/signout (drops the server session and expires the
cookie) and a "not this position? switch" link beside the identity line on
/suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:07:29 -05:00
martbost c039c04ceb Suite: entitle the positions above our team root, not just below it
The org test only ever looked downward: you are in if a root appears in your
upline chain. The founders and contract owners sit ABOVE #21, so #2 - a Corona
position that has been here since the beginning - was told "this position isn't
inside our team's organization" and shown a locked wall.

That is backwards. Their positions are the reason the contract exists.

Each root's own upline is now entitled as well, which covers the chain up
through #2 and the root account. A position's upline is fixed at registration,
so it is resolved once and memoised rather than walked per request.

Nothing else changes: level still comes from the contract, the allowlist and
level-override behave as before, and members below the root are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:51:16 -05:00
martbost 7e390aa48e Join: let chosen positions default to direct placement
The moving link is team-first by design: it routes a new join to whoever needs
directs next, so the position whose link was actually clicked earns no entry
reward. Measured on #21 that came to 80 joins in seven days and zero entry
rewards, while the traffic paying for those joins was the owner's.

For a position being deliberately built out, that default is backwards. Any ID
listed in the new directDefaultIds config now behaves as ?direct=1 on its own
/join/<id> link - including every promo tool, flyer QR and downline-builder
entry that resolves through it - without needing the parameter appended.

?direct=0 still forces rotation for a single share, so the team-first behaviour
stays available rather than being replaced.

Config rather than hard-coded, and editable from the admin settings form, so
which positions get this can change without a deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:29:55 -05:00
martbost 25eddf19c4 Dashboard: show what an upgrade buys, not just what it costs
The upgrade panel showed a price, a funded badge and a button. Nothing on it
said what the member would get. 53 of 87 qualified members in the org are
parked at Ascensus, and a panel that reads as a bill is part of why.

It now leads with what the rung unlocks, in the member's own numbers: the
generation it extends reach to, how many people they already have sitting
there, and what each of those pays when they climb. genCounts already existed
in getOrgShare; this just points it at the member instead of at the admin.

The line that matters most is arithmetic, not persuasion: because every rung
costs exactly twice the last, the first catch at the new level is exactly
double what the upgrade cost. That is Lesson 8's whole argument, and the
panel now makes it where the decision is actually taken rather than leaving
it in a lesson nobody opens at the right moment.

Wrapped in try/catch — if the org walk fails the panel renders as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 05:18:10 -05:00
martbost c86a583488 Chatbot: the Suite is live, stop telling the bot to hide it
The AI system prompt still carried "CURRENTLY IN PRIVATE TEAM BETA: do NOT
proactively promote it; if a member asks, say it is being finalized and will
be announced soon." That stopped being true when the Suite launched to the
org. /suite has been serving publicly, and the best-performing massifly story
to date sells precisely this — so the bot was deflecting questions about the
strongest answer we have to "what's the actual product".

The prompt now says it is live, open to every position, and should lead any
what-do-I-actually-get question.

chat.js had no Suite entry at all, so the canned side never mentioned it
either. Added one covering the product question in the words people actually
use, with the level ladder and the honest note that in-development tiles are
labelled as such.

Both sides updated together — they drift apart the moment only one is
touched, which is how this one survived the launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:14:02 -05:00
martbost 05afe5c5f6 Admin: award Suite capacity to any member as a team bonus
Marty asked for a way to grant impressions to anybody, labelled as an admin
bonus. Team Grants already did this for leaders, but only within their own
organisation and only out of a level pool.

adminGrant() is deliberately a different thing: it comes from no pool (the
team is not a position and has no allowance), it can reach ANY registered
position rather than only someone downline, and it carries a reason that
survives in the ledger. Recorded with by:0 so nothing downstream mistakes it
for a leader's grant — the member's own page renders those as "the team"
rather than "#0".

The route verifies the position exists on-chain before crediting it; a typo
would otherwise sit in the ledger crediting nobody, and it would be invisible
until someone went looking.

Awards land on top of whatever the level already allows and expire with the
month like every other allowance. The admin page lists what has been awarded
this month, so bonuses stay auditable rather than vanishing once given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 05:40:00 -05:00
martbost 26f2c91684 Banners must stop when they have delivered what was bought
Marty: "if it's just gonna keep running, why would anyone use impressions to
place the ad at all?" He is right, and my previous answer — calling the
over-delivery a bonus — papered over a real flaw.

Banner placements on this network have no cap. Left alone, 2,500 impressions
buys an ad that runs until expiry, which makes the allowance decorative and
the whole level ladder (2,500 at Scintilla to 150,000 at Corona) worth
nothing. Nobody would ever spend the larger allowance for an identical
outcome.

Two changes so the impressions are genuinely the thing being spent:

sweepCompleted() deactivates any campaign that has served its purchased
amount. Runs 90s after boot and every 15 minutes, batches its stat reads, and
only touches campaigns that are live, un-stopped and provably at or past what
was bought. Nothing is refunded — they delivered in full. It re-reads the
ledger after the network calls, since deactivating takes real time and
another member may have launched in that window.

New campaigns get days:30 instead of days:365. The allowance is monthly, so a
campaign outliving the month it was paid from is the same bug by another
route. Belt and braces — whichever ends it first.

Copy corrected too: "still serving as a bonus" became "served in full —
closing out", and the footnote now states plainly that a campaign ends when
it has served what was bought.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 05:23:38 -05:00
martbost 86bba1b605 Actually fix the missing joiner alert — the org test, not the emit
My previous fix was at the wrong layer. Proof: the announced-once set shows a
"reg:" key written at 13:53:40, so the registered event fired correctly. It
was being discarded one layer later.

The real cause: the team-topic filter asks isInTeam(newMemberId), which walks
uplineId — and a brand-new member has no uplineId at the moment the event
fires. The indexer wires the matrix in a SEPARATE pass after the log loop
(chain.js:359), so at emit time the walk has nothing to follow, the org test
returns false, and the announcement is dropped. Payouts survive because they
reference existing members whose uplines are already known. That asymmetry is
exactly what Marty kept seeing: the member who got paid, never the joiner.

Registered events now test the referrer as well. The referrer is always
already wired, and anyone joining under someone in the org is in the org, so
it is correct as well as available. Confirmed on #255: uplineId 246,
referrerId 246 — the referrer was resolvable the whole time.

Keeping the announced-once change from before: it fixes a real second bug
(snapshot landing first suppressed the emit) and both had to be right.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 09:03:18 -05:00
martbost 962f0c8892 Stop offering "Connect Telegram" to members who are already connected
Janie linked successfully and the dashboard kept showing the Connect button,
so a working link looked like a failed one — and she had no way to tell which.

The Messages panel had no idea about link state: msg-inbox never reported it.
It now returns tgLinked (from tgbot.memberChat) on the response the panel
already fetches, so this costs no extra request. Linked members get a
confirmation telling them where their pings arrive and how to open the Mini
App, instead of a button asking them to do something already done.

Third variant today of the same failure: the interface not knowing a state it
could easily have known, and the member left guessing. The bot had it too —
telling an already-linked member their code had expired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 07:05:26 -05:00
martbost a57af32f4a Co-Branded Pages: give Fastigium a change of kind, not a bigger number
Once every member from Ascensus up could hold several pages, the Funnel
Factory's differentiator became a count — the same quantity-not-kind flaw the
upper tiers had. L6 now unlocks identity instead: a member's own name,
monogram and accent colour on every page they build. Below Fastigium you have
a page on the team site; at Fastigium it becomes yours.

Three deliberate limits.

NO IMAGE UPLOADS. A logo file uploaded by a member and served from
rmcircle.team is an abuse surface — whatever they upload lives on our domain.
The mark is a monogram generated from their brand name instead: real identity,
nothing to moderate. Same reasoning that keeps Traffic Desk creative
team-only.

ACCENTS ARE A CURATED SET, not a colour picker. Every option is checked
against the dark ground, so nobody can pick something that makes their own
page unreadable and then wonder why it converts badly.

THE DISCLAIMERS ARE NOT THEIRS TO BRAND. Branding replaces the header
identity only; the footer — independent team resource, no income guaranteed,
cryptocurrency risk — is rendered by us and cannot be removed or restyled.
Co-branding, not white-labelling. Verified in the renderer.

Branding is resolved against the LIVE contract level at render time, so a
position that drops below Fastigium reverts to team identity on its own
rather than keeping something it no longer holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 06:14:28 -05:00
martbost c8ae4d55b0 Page Builder: members can hold several pages, scaled by level
The quota and the capability used to contradict each other. An Ascensus
member was allowed 3 page builds a month, but every build overwrote the same
file — so they could build three times and never have more than one page. The
slug plumbing to do better already existed and was only exposed at level 6,
which made the Funnel Factory's differentiator a COUNT rather than a KIND, the
same flaw the upper tiers had.

Now: hold caps by level (2 at Ascensus rising to 50 at Corona), separate from
the monthly build quota. Rebuilding spends a build but needs no hold room;
adding a new page needs both. The Page Builder is a list of your pages with
view / copy link / rebuild / delete, plus a form that ADDS one instead of
silently replacing what you had.

Two things kept deliberately fixed. The first page a member builds stays at
/p/<id> forever and cannot be deleted — that address ends up on banners,
printed flyers and in people's DMs, so it must never move. Extra pages take a
slug from the headline the writer produced, de-duplicated, so the address
describes the page without anyone having to invent one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 06:02:06 -05:00
martbost 3620a72338 Wallet diagnostics: a check page, and an error that names the signer
Two sign-in failures with different causes and no way to tell them apart from
the outside, so stop guessing and get the data off the member's device.

/wallet-check reports what the page can actually see: whether window.ethereum
exists, which wallet flags are set, which EIP-6963 wallets announced, the
accounts and chain the wallet returns — and critically, any CSP violation.
Our script-src is 'self' with no 'unsafe-inline', and a wallet browser that
injects its provider via a script tag would be blocked silently, producing
symptoms identical to "no wallet installed". The company dApp sends no CSP at
all, which is a plausible reason it connects where we do not. This page will
confirm or kill that theory rather than us theorising further.

"Signature does not match this wallet" was true but useless — it never said
WHICH wallet signed. It now names both the address the page asked for and the
address that actually signed, which identifies the classic multi-account case
(wallet signs with the selected account, not the one the page picked up) in
one glance.

Verified for the case in hand: 0x20E0…2248 IS the wallet registered to
position #52 on-chain, so ownership was never the problem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:53:34 -05:00