Build Plan

What to do next, in order, with the steps to do it
🧭23 items. Ranked worst-first β€” things actively losing money before things merely unbuilt. Every row names an owner, the steps, and how you know it worked. Source of truth is tools/build-plan/plan.mjs; change it by opening a PR, not by editing this screen.
16
Ready now
Nothing is blocking these β€” start here
7
Waiting on something
Blocked by another item on this board
5
Breaking in production
~23% of SMS is being dropped today
8
Tracked in GitHub
Have an issue number already

The plan

Tracks run in parallel; steps inside a track are ordered. Filter by who is doing the work β€” most of the top of this board is Cole in a DNS panel, not engineering. Click a row to open its steps.

Deliverability

Breaking in production right now
BP-01Fix the apex SPF record so staff email authenticatesColeminutes#57

Why: The apex SPF authorises GoDaddy only. Microsoft 365 is not in it, so staff mail sent from worldwellnessclubs.com is failing SPF today β€” before any campaign exists.

Unblocks: Every other email fix. A domain that cannot authenticate its own staff mail cannot be warmed for marketing.

Steps
  1. In GoDaddy DNS for worldwellnessclubs.com, find the existing TXT record at @ beginning `v=spf1`.
  2. Replace its value with: v=spf1 include:spf.protection.outlook.com include:secureserver.net ~all
  3. Do not add a second SPF record. Two `v=spf1` records is a permerror, which is worse than one wrong record.
  4. Keep total DNS lookups at or below 10 β€” each `include:` costs one.
  5. Save and wait for TTL to expire before testing.

Done when: Send from a staff mailbox to a Gmail address. Open the message, Show original, and confirm SPF=PASS.

docs/ops/runbooks/email-authentication.md#step-1 Β· issue #57

BP-02Enable DKIM for Microsoft 365Coleminutes#58blocked

Why: Neither selector1 nor selector2 is published, so nothing signs staff mail. SPF alone is not enough for DMARC alignment.

Unblocks: DMARC. Without DKIM, publishing DMARC will start failing legitimate mail.

Waiting on: BP-01

Steps
  1. In GoDaddy DNS, add CNAME `selector1._domainkey` β†’ selector1-worldwellnessclubs-com._domainkey.NETORGFT18259975.onmicrosoft.com
  2. Add CNAME `selector2._domainkey` β†’ selector2-worldwellnessclubs-com._domainkey.NETORGFT18259975.onmicrosoft.com
  3. Publish BOTH CNAMEs before touching Microsoft. Order matters β€” Microsoft validates them before it will enable signing, and publishes nothing at the targets until it does.
  4. In Microsoft 365 Defender β†’ Email & collaboration β†’ Policies β†’ Email authentication β†’ DKIM, select the domain and switch "Sign messages for this domain with DKIM signatures" to on.

Done when: Same Gmail Show original check β€” DKIM=PASS with d=worldwellnessclubs.com.

docs/ops/runbooks/email-authentication.md#step-2 Β· issue #58

BP-03Publish DMARC in monitoring modeColeminutes#59blocked

Why: _dmarc is NXDOMAIN β€” there is no record at all, so nothing anywhere reports authentication failures. We are blind to our own deliverability.

Unblocks: Evidence. Two weeks of aggregate reports is what tells us whether it is safe to move to p=quarantine.

Waiting on: BP-02

Steps
  1. Create the mailbox or alias dmarc@worldwellnessclubs.com first, or the reports go nowhere.
  2. Add TXT record at `_dmarc` with value: v=DMARC1; p=none; rua=mailto:dmarc@worldwellnessclubs.com; fo=1
  3. p=none is deliberate. It changes nothing about delivery and only turns reporting on.
  4. Leave it at p=none for at least two weeks and read the reports before tightening.

Done when: dig TXT _dmarc.worldwellnessclubs.com returns the record, and aggregate reports start arriving within ~48h.

docs/ops/runbooks/email-authentication.md#step-3 Β· issue #59

BP-04Give GoHighLevel its own sending subdomainColehours#60blocked

Why: GHL currently has no authenticated way to send as this domain. Sending marketing from the apex would also put campaign reputation on the same domain as staff email.

Unblocks: Phase 1 email. Also isolates any reputation damage from the domain the business runs on.

Waiting on: BP-03

Steps
  1. In the GHL sub-account, Settings β†’ Email Services β†’ Dedicated Domain, add mail.worldwellnessclubs.com.
  2. GHL displays the exact records to publish β€” typically DKIM CNAMEs and a return-path. Publish them verbatim in GoDaddy.
  3. Add TXT at `_dmarc.mail` with: v=DMARC1; p=none; rua=mailto:dmarc@worldwellnessclubs.com
  4. Wait for GHL to show the domain as verified before sending anything.

Done when: GHL reports the domain verified, and a test send from GHL passes SPF, DKIM and DMARC at Gmail.

docs/ops/runbooks/email-authentication.md#step-4 Β· issue #60

BP-05Verify A2P 10DLC registration is approved, not merely submittedColehours#61

Why: Roughly 23% of SMS is being silently dropped. Carriers filter unregistered traffic and GHL surfaces no error, so this looks like poor engagement rather than a blocked channel. SMS is the primary channel here β€” only 4,344 of 13,331 contacts have an email at all.

Unblocks: Hard blocker 1. No SMS campaign may run until this reads APPROVED.

Steps
  1. In the GHL sub-account, Settings β†’ Phone Numbers β†’ Trust Center, read the brand and campaign status.
  2. Distinguish SUBMITTED / PENDING from APPROVED. Only APPROVED counts.
  3. If not registered: register the brand (~$15), then the campaign (~$40–50 vetting), then expect ~$10–15/mo.
  4. Record the use case and sample messages used at registration β€” carriers audit against them, and sending copy that differs from what was vetted is how a campaign gets shut off.
  5. Update the gate checkbox in ENGAGEMENT.md once approved.

Done when: Trust Center shows APPROVED for both brand and campaign, and a pilot send to 10 known-good numbers reaches all 10.

docs/ops/outbound-compliance.md Β· docs/ops/cost-stack.md Β· issue #61

Legal & consent

Gates every outbound send and all member footage
BP-06Separate the inbound cohort from the ~10,900 cold prospecting contactsCTOhours

Why: The account holds 13,331 contacts but only ~1,144 are inbound leads. The rest are phone-append prospecting lists that never opted in to anything. TCPA damages run $500–$1,500 per message.

Unblocks: Hard blocker 2 β€” and it comes first, because the other three gates are meaningless if the audience is wrong.

Steps
  1. Export contacts with created date, source, tags and consent fields to data/raw/ (gitignored).
  2. Tag the inbound cohort explicitly β€” do not rely on absence of a cold tag, which fails open.
  3. Build a GHL smart list restricted to the inbound tag, and make every campaign target that list rather than a filter written per send.
  4. Spot-check 20 records by hand against the tag before trusting it.
  5. Record the resulting counts in docs/research/contact-base.md. Aggregates only β€” never names, emails or phones.

Done when: The smart list count matches the measured inbound figure, and no campaign in the account targets "All Contacts".

docs/research/contact-base.md Β· CLAUDE.md

BP-07Document consent age and source for the inbound cohortMaverickhoursblocked

Why: Aged leads with no recent documented consent are TCPA exposure on SMS. We currently cannot state when or how any of these contacts consented.

Unblocks: Hard blocker 3.

Waiting on: BP-06

Steps
  1. Produce the exact SMS consent language shown at capture, and the date it went live.
  2. Confirm whether the consent timestamp is stored per contact in GHL, or only implied by created date.
  3. Split the cohort: consent documented and recent Β· documented and aged Β· no documented consent.
  4. The third group is email-only. It does not receive SMS at any point.
  5. Write the split and its reasoning into docs/ops/outbound-compliance.md.

Done when: Every contact in the SMS-eligible segment has a consent source and date on the record.

docs/ops/outbound-compliance.md

BP-08Put a media release clause in the membership agreementMaverickhours

Why: Without it the best footage in the building β€” the genuine first-plunge reaction, the testimonial that converts β€” is legally unusable. Chasing signatures retroactively for footage already shot is miserable and usually fails.

Unblocks: Lane A of the UGC machine, which is every asset showing a real member. Until this exists the content engine can only produce faceless brand content.

Steps
  1. Add one clause at signup granting photo and video permission for marketing use.
  2. Add a separate parent/guardian signature path for minors β€” youth sports partnerships need it before the team walks in.
  3. Have counsel confirm the clause covers paid media, not only organic.
  4. Decide and record where signed releases are stored, and how the content engine checks one exists before publishing.
  5. Backfill: identify any existing footage worth keeping and get releases now, before more accumulates.

Done when: A new member signs up and the release is on file automatically, with no separate step for staff to remember.

docs/strategy/content-program.md Β· platform/ugc-machine-spec.md

BP-09Publish the privacy policy and termsMaverickhours

Why: Neither page exists on the live site. Drafts are written and sitting in docs/legal/. Meanwhile GitHub #16 records that a contact list was uploaded to Meta and Google custom audiences β€” performed data sharing with no published policy describing it is a worse position than either problem alone.

Unblocks: Ad platform compliance, and removes a live exposure that grows with every lead captured.

Steps
  1. Review docs/legal/privacy-policy.draft.md and terms-of-service.draft.md with counsel.
  2. Confirm the privacy policy explicitly covers custom-audience upload to Meta and Google.
  3. Publish both at stable URLs and link them from the site footer and every lead form.
  4. Record the publication date β€” it is the date the policy starts being defensible.

Done when: Both pages load, are linked from every form, and counsel has signed off on the data-sharing paragraph.

docs/legal/privacy-policy.draft.md Β· docs/legal/terms-of-service.draft.md

BP-10Name the supervising medical provider for peptides, GLP-1 and IV/TRTMaverickdays

Why: R1-02 and Tier 0 question L1. Prescribing services are being marketed with no publicly identified medical supervision, in a state with an active regulatory posture and alongside FDA warnings about HBOT promotion.

Unblocks: Any marketing at all of those services β€” organic, paid, creator or synthetic. Until it resolves, the content engine excludes them entirely.

Steps
  1. Identify the supervising provider and their licence, in writing.
  2. Confirm with counsel whether the clinical entity needs an AHCA Health Care Clinic licence (question L3).
  3. Publish the supervising provider on the site where those services are described.
  4. Until resolved, mark peptides, GLP-1, IV and TRT as excluded in the content linter rather than relying on memory.

Done when: R1-02 can be moved to closed in docs/research/risk-register.md with a named provider and a licence number.

docs/research/risk-register.md Β· docs/research/florida-regulatory.md

Public site

Visibly broken to anyone who visits
BP-11Fix the dead nav and footer linksCTOhours

Why: The whole nav and footer are dead links on a live site that is capturing leads. Every one is a visitor who tried to learn something and hit nothing.

Unblocks: Paid traffic. Sending spend to a site whose navigation does not work wastes the click.

Steps
  1. Enumerate every nav and footer link and its current destination.
  2. For each: point it at a real page, or remove it. A removed link is better than a dead one.
  3. Re-run the site-wide soft-404 check recorded in the P0 verification commit.

Done when: No link in the nav or footer returns a 404 or a soft-404.

docs/ops/runbooks/seo-verification-checklist.md

BP-12Remove the 13 instances of lorem ipsum from the homepageCTOhours

Why: Thirteen blocks of placeholder Latin on the homepage of a business asking people to book a consultation. It is the single most visible credibility problem the brand has.

Unblocks: Any campaign that lands on the homepage.

Steps
  1. Locate all 13 instances.
  2. Replace each with real copy from copy/, or delete the section outright.
  3. Where the true content is unknown, delete rather than invent β€” CLAUDE.md rule 4. Assumptions that leak into copy get read by real customers.

Done when: grep the rendered homepage for "lorem" and "ipsum" returns nothing.

copy/README.md

The three numbers

Without these no spend decision can be judged
BP-13Get membership tiers, prices, contract terms and enrolment feesMaverickminutes

Why: Without price and contract length, cost per acquisition cannot be judged good or bad. A $475 cost per membership is excellent against $250/mo and terrible against $79/mo.

Unblocks: Every spend decision, and the LTV side of the PE readiness model.

Steps
  1. List every tier with price, contract length, enrolment fee and what is included.
  2. Confirm whether memberships auto-renew (question L6) β€” it changes the terms page and the cancellation flow.
  3. Confirm what the $300 credit may be applied to: services, enrolment, or first month.
  4. Record in docs/ops/open-questions.md and mark #10 and #4 answered.

Done when: A closer can quote price and terms without asking anyone.

docs/ops/cost-stack.md Β· docs/ops/open-questions.md

BP-14Establish consultation capacity per week, and who runs themMaverickminutes

Why: Capacity caps every acquisition channel simultaneously. Spending past it does not buy members β€” it buys no-shows and a bad reputation.

Unblocks: The pilot send. Sizing a campaign above capacity is how a reactivation destroys goodwill.

Steps
  1. State consultations per week the club can actually host, and who hosts them.
  2. Describe how the 15-minute consultation runs, start to finish (question 5b).
  3. Set the pilot size at or below capacity β€” the plan is 50 contacts, which must not exceed what can be seen.
  4. Define what happens when the host is unavailable.

Done when: A weekly capacity number exists, and the pilot is sized against it rather than against list size.

campaigns/consultation-experience.md Β· docs/ops/open-questions.md

BP-15Baseline current revenue, member count, average price and churnMaverickhoursblocked

Why: Churn converts a monthly price into an actual LTV, and a membership business lives or dies on it. Every projection in this repo is an estimate until these land.

Unblocks: PE readiness scoring, paid media budgets, and the hiring case for a closer.

Waiting on: BP-13

Steps
  1. Pull current MRR, active member count, average membership price and monthly churn.
  2. Separate seasonal departures from real cancellations β€” Palm Beach County empties from April, and without the split every spring looks catastrophic and nobody trusts the dashboard.
  3. Record the consultation→membership close rate, even roughly.
  4. Enter as the baseline that replaces every estimate in the repo.

Done when: The KPI screen shows real figures rather than placeholders, and the cohort view distinguishes seasonal from churned.

docs/strategy/pe-readiness.md Β· apps/web/src/pages/dashboard/kpi.astro

Platform build

What the CTO builds next
BP-16Build raw content ingest with Whisper transcriptionCTOdays

Why: The machine must accept raw material, not only briefs. Today nobody can find the moment a first-timer gasped without scrubbing an hour of rushes, so that footage effectively does not exist.

Unblocks: Everything downstream in the content engine, and it works on footage the club already has.

Steps
  1. Install a Whisper runtime on the VPS. ffmpeg 6.1.1 is already installed.
  2. Build the ingest path: accept phone video, photos, voice memos, screen recordings and batch filming-day dumps.
  3. On ingest, transcode to a normalised master, transcribe, thumbnail, and write a lineage record.
  4. Default every item to lane unset and release unreleased, so it is unpublishable until a human sets both. Defaulting to publishable will eventually publish an unreleased face.
  5. Propose filenames in the modality-type-YYYY-MM-DD convention from the transcript and shoot date.
  6. Index transcripts so the library is searchable by what was said.
  7. Back-ingest the existing 194 Instagram posts so the library does not start empty.

Done when: Searching the library for a spoken phrase returns the clip and its timestamp.

platform/ugc-machine-spec.md#stage-0

BP-17Build the ffmpeg repurposer and synthetic camera movesCTOdaysblocked

Why: This is the one-filmed-session-becomes-ten-assets leverage, and it needs no AI video generation at all. It also turns the 194 existing static posts into Reels at zero marginal cost.

Unblocks: Daily publishing cadence from one filming day per week.

Waiting on: BP-16

Steps
  1. Cut to the shot list, burn captions from Whisper word timings, and render 9:16, 1:1 and 16:9.
  2. Normalise loudness to each platform target.
  3. Implement synthetic camera moves by animating a crop over time β€” push-in, pull-back, lateral drift, parallax on stills.
  4. Apply the house defaults in platform/shot-grammar.md, including the rule that a reaction shot does not move.
  5. Make every derivative inherit its parent lineage record, including provenance.

Done when: One source clip produces ten platform-correct derivatives, and each one traces back to its parent and release status.

platform/shot-grammar.md Β· platform/ugc-machine-spec.md#stage-4

BP-18Wire publishing through the GoHighLevel Social Planner APICTOdays

Why: HubLayout.astro shelved the posting engine because Social Planner "is not in the public API". That is no longer true β€” API v2 exposes POST /social-media-posting/:locationId/posts plus CRUD, accounts, OAuth and statistics. The engine was parked on a premise that has expired.

Unblocks: The entire posting engine. Until this exists everything upstream produces output with nowhere to go.

Steps
  1. Get GHL sub-account access β€” API key and location ID (open question #1). This blocks the work, not the design.
  2. Confirm in the live account which networks connect on this plan, media and length limits, and rate limits.
  3. Verify whether YouTube is supported. It is priority 5 and the one network with search longevity; if absent, that single network goes to Postiz and nothing else does.
  4. Verify whether Social Planner can set each network AI-content label. If it cannot, lanes B and C publish manually.
  5. Implement fan-out: post repeatedly with different summaries and account IDs for per-network captions.
  6. Attach UTM parameters to every CTA so a booked consultation attributes back to a post.
  7. Move Posting Engine out of the "Not built yet" group in HubLayout.astro once it publishes.

Done when: An approved asset publishes to Instagram and Google Business Profile from the dock, and the statistics endpoint returns its performance.

platform/ugc-machine-spec.md Β· apps/web/src/layouts/HubLayout.astro

BP-19Build the Claude script and hook generatorCTOdays

Why: Zero legal risk, works with no footage, and addresses the actual finding that the content exists but the audience does not.

Unblocks: Filming days that produce usable material, because the shot list exists before the camera comes out.

Steps
  1. Generate 10 ranked hooks per brief β€” the first three seconds are the whole asset.
  2. Generate the script against hook/demo/payoff, and a shot list using the six anchors.
  3. Draw camera anchors from platform/shot-grammar.md rather than improvising them.
  4. Generate per-platform captions against the voice rules in copy/README.md.
  5. Run every output through tools/ugc/lint.mjs before it reaches a human.
  6. Size the generator to what the dock can approve, not to what the model can produce.

Done when: A brief produces a linted script, shot list and caption set that a person can shoot against without further instruction.

platform/ugc-machine-spec.md#stage-2 Β· tools/ugc/lint.mjs

BP-20Build the /dashboard/content approval dockCTOdaysblocked

Why: One human, one queue, 60 seconds per item. The gate is the point in a health-adjacent business, and nothing publishes without it.

Unblocks: Operator use of everything above.

Waiting on: BP-16, BP-18, BP-19

Steps
  1. Port the dock, linter panel and performance chart from platform/hub/content.html.
  2. Add the lane selector to the brief form, driven by lanesFor() so an illegal combination cannot be chosen.
  3. Add a provenance badge to every card β€” an approver must see without clicking whether this is a real member or a generated person.
  4. Show release status for every person visible in frame.
  5. Keep linter-failed drafts unapprovable, reusing the existing behaviour rather than inventing a second mechanism.
  6. Resolve who approves and who is the backup approver (question C11) β€” a queue with no backup stops the engine for three days.

Done when: An asset moves ingest β†’ generate β†’ lint β†’ approve β†’ publish without leaving the dashboard.

platform/hub/content.html Β· platform/ugc-machine-spec.md#stage-4

Document integrity

The repo contradicting itself
BP-21Reconcile the nine tour-first strategy documentsCTOhours#19

Why: The funnel was changed to consultation-first on 2026-08-01, but "consultation" appears zero times across nine files in docs/strategy/. This is the largest single inconsistency in the repo, and anyone reading those files will build the wrong funnel.

Unblocks: Trust in the strategy folder. A document set that contradicts the current plan gets ignored wholesale.

Steps
  1. List every file in docs/strategy/ containing tour-first framing (issues #15 and #19 name them).
  2. Apply the vocabulary settled in commit 12a9e27 β€” consultation is the conversion event, a tour is a thing that may happen during one.
  3. Fix outreach-engine.md and tour-experience.md specifically, which issue #15 calls out.
  4. Close #15 and #19 together β€” they are the same defect found twice.

Done when: grep -ri "tour" docs/strategy/ returns only intentional uses, and "consultation" appears in every funnel description.

campaigns/consultation-experience.md Β· issue #19

BP-22Correct the $300 credit framing in four filesCTOhours#14

Why: The 08-01 correction established the $300 credit as a partner marketing tool, not a consumer offer. Four files still market it direct to consumers β€” including content-program.md, which was merged during the reconciliation pass that was supposed to catch exactly this.

Unblocks: Campaign copy that does not contradict itself, and removes the risk of the wrong offer reaching real customers.

Steps
  1. Locate the four files (issues #14 and #20).
  2. Reframe the credit as a partner tool in each, or state explicitly where a consumer-facing use is intentional and approved.
  3. Confirm against question #4 β€” what the credit may actually be applied to β€” before rewriting, so the correction is not itself a guess.
  4. Close #14 and #20.

Done when: Every mention of the $300 credit states who it is for, consistently.

docs/ops/open-questions.md Β· issue #14

BP-23Refresh the roadmap and the engagement status lineCTOhours#13

Why: roadmap.md is dated 2026-08-01 with every Phase 0 box unticked, and ENGAGEMENT.md still reads "Discovery β€” awaiting client data and access". Both predate a live dashboard running on real GoHighLevel data, twelve built screens, and the Tier 0 legal findings.

Unblocks: A sequencing document people can trust. This plan replaces the per-phase checklists; the roadmap should point at it rather than compete with it.

Steps
  1. Tick what is genuinely done in roadmap.md Phase 0 and state what remains.
  2. Update the ENGAGEMENT.md status line to reflect that the platform is built and the blockers are legal and access, not scaffolding.
  3. Point roadmap.md at this build plan for the step-level detail instead of duplicating it.
  4. Close #13.

Done when: A newcomer reading ENGAGEMENT.md and roadmap.md forms an accurate picture of where the project actually is.

docs/ops/roadmap.md Β· ENGAGEMENT.md Β· issue #13

Built from tools/build-plan/plan.mjs Β· 23 items Β· integrity asserted by npm run build-plan:test