Build Plan
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 nowBP-01Fix the apex SPF record so staff email authenticates
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.
- In GoDaddy DNS for worldwellnessclubs.com, find the existing TXT record at @ beginning `v=spf1`.
- Replace its value with: v=spf1 include:spf.protection.outlook.com include:secureserver.net ~all
- Do not add a second SPF record. Two `v=spf1` records is a permerror, which is worse than one wrong record.
- Keep total DNS lookups at or below 10 β each `include:` costs one.
- 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 365
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
- In GoDaddy DNS, add CNAME `selector1._domainkey` β selector1-worldwellnessclubs-com._domainkey.NETORGFT18259975.onmicrosoft.com
- Add CNAME `selector2._domainkey` β selector2-worldwellnessclubs-com._domainkey.NETORGFT18259975.onmicrosoft.com
- 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.
- 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 mode
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
- Create the mailbox or alias dmarc@worldwellnessclubs.com first, or the reports go nowhere.
- Add TXT record at `_dmarc` with value: v=DMARC1; p=none; rua=mailto:dmarc@worldwellnessclubs.com; fo=1
- p=none is deliberate. It changes nothing about delivery and only turns reporting on.
- 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 subdomain
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
- In the GHL sub-account, Settings β Email Services β Dedicated Domain, add mail.worldwellnessclubs.com.
- GHL displays the exact records to publish β typically DKIM CNAMEs and a return-path. Publish them verbatim in GoDaddy.
- Add TXT at `_dmarc.mail` with: v=DMARC1; p=none; rua=mailto:dmarc@worldwellnessclubs.com
- 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 submitted
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.
- In the GHL sub-account, Settings β Phone Numbers β Trust Center, read the brand and campaign status.
- Distinguish SUBMITTED / PENDING from APPROVED. Only APPROVED counts.
- If not registered: register the brand (~$15), then the campaign (~$40β50 vetting), then expect ~$10β15/mo.
- 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.
- 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 footageBP-06Separate the inbound cohort from the ~10,900 cold prospecting contacts
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.
- Export contacts with created date, source, tags and consent fields to data/raw/ (gitignored).
- Tag the inbound cohort explicitly β do not rely on absence of a cold tag, which fails open.
- Build a GHL smart list restricted to the inbound tag, and make every campaign target that list rather than a filter written per send.
- Spot-check 20 records by hand against the tag before trusting it.
- 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 cohort
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
- Produce the exact SMS consent language shown at capture, and the date it went live.
- Confirm whether the consent timestamp is stored per contact in GHL, or only implied by created date.
- Split the cohort: consent documented and recent Β· documented and aged Β· no documented consent.
- The third group is email-only. It does not receive SMS at any point.
- 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 agreement
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.
- Add one clause at signup granting photo and video permission for marketing use.
- Add a separate parent/guardian signature path for minors β youth sports partnerships need it before the team walks in.
- Have counsel confirm the clause covers paid media, not only organic.
- Decide and record where signed releases are stored, and how the content engine checks one exists before publishing.
- 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 terms
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.
- Review docs/legal/privacy-policy.draft.md and terms-of-service.draft.md with counsel.
- Confirm the privacy policy explicitly covers custom-audience upload to Meta and Google.
- Publish both at stable URLs and link them from the site footer and every lead form.
- 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/TRT
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.
- Identify the supervising provider and their licence, in writing.
- Confirm with counsel whether the clinical entity needs an AHCA Health Care Clinic licence (question L3).
- Publish the supervising provider on the site where those services are described.
- 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 visitsBP-11Fix the dead nav and footer links
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.
- Enumerate every nav and footer link and its current destination.
- For each: point it at a real page, or remove it. A removed link is better than a dead one.
- 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 homepage
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.
- Locate all 13 instances.
- Replace each with real copy from copy/, or delete the section outright.
- 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 judgedBP-13Get membership tiers, prices, contract terms and enrolment fees
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.
- List every tier with price, contract length, enrolment fee and what is included.
- Confirm whether memberships auto-renew (question L6) β it changes the terms page and the cancellation flow.
- Confirm what the $300 credit may be applied to: services, enrolment, or first month.
- 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 them
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.
- State consultations per week the club can actually host, and who hosts them.
- Describe how the 15-minute consultation runs, start to finish (question 5b).
- Set the pilot size at or below capacity β the plan is 50 contacts, which must not exceed what can be seen.
- 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 churn
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
- Pull current MRR, active member count, average membership price and monthly churn.
- 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.
- Record the consultationβmembership close rate, even roughly.
- 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 nextBP-16Build raw content ingest with Whisper transcription
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.
- Install a Whisper runtime on the VPS. ffmpeg 6.1.1 is already installed.
- Build the ingest path: accept phone video, photos, voice memos, screen recordings and batch filming-day dumps.
- On ingest, transcode to a normalised master, transcribe, thumbnail, and write a lineage record.
- 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.
- Propose filenames in the modality-type-YYYY-MM-DD convention from the transcript and shoot date.
- Index transcripts so the library is searchable by what was said.
- 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 moves
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
- Cut to the shot list, burn captions from Whisper word timings, and render 9:16, 1:1 and 16:9.
- Normalise loudness to each platform target.
- Implement synthetic camera moves by animating a crop over time β push-in, pull-back, lateral drift, parallax on stills.
- Apply the house defaults in platform/shot-grammar.md, including the rule that a reaction shot does not move.
- 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 API
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.
- Get GHL sub-account access β API key and location ID (open question #1). This blocks the work, not the design.
- Confirm in the live account which networks connect on this plan, media and length limits, and rate limits.
- 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.
- Verify whether Social Planner can set each network AI-content label. If it cannot, lanes B and C publish manually.
- Implement fan-out: post repeatedly with different summaries and account IDs for per-network captions.
- Attach UTM parameters to every CTA so a booked consultation attributes back to a post.
- 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 generator
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.
- Generate 10 ranked hooks per brief β the first three seconds are the whole asset.
- Generate the script against hook/demo/payoff, and a shot list using the six anchors.
- Draw camera anchors from platform/shot-grammar.md rather than improvising them.
- Generate per-platform captions against the voice rules in copy/README.md.
- Run every output through tools/ugc/lint.mjs before it reaches a human.
- 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 dock
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
- Port the dock, linter panel and performance chart from platform/hub/content.html.
- Add the lane selector to the brief form, driven by lanesFor() so an illegal combination cannot be chosen.
- Add a provenance badge to every card β an approver must see without clicking whether this is a real member or a generated person.
- Show release status for every person visible in frame.
- Keep linter-failed drafts unapprovable, reusing the existing behaviour rather than inventing a second mechanism.
- 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 itselfBP-21Reconcile the nine tour-first strategy documents
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.
- List every file in docs/strategy/ containing tour-first framing (issues #15 and #19 name them).
- Apply the vocabulary settled in commit 12a9e27 β consultation is the conversion event, a tour is a thing that may happen during one.
- Fix outreach-engine.md and tour-experience.md specifically, which issue #15 calls out.
- 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 files
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.
- Locate the four files (issues #14 and #20).
- Reframe the credit as a partner tool in each, or state explicitly where a consumer-facing use is intentional and approved.
- Confirm against question #4 β what the credit may actually be applied to β before rewriting, so the correction is not itself a guess.
- 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 line
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.
- Tick what is genuinely done in roadmap.md Phase 0 and state what remains.
- Update the ENGAGEMENT.md status line to reflect that the platform is built and the blockers are legal and access, not scaffolding.
- Point roadmap.md at this build plan for the step-level detail instead of duplicating it.
- 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
Nothing matches that filter.
tools/build-plan/plan.mjs Β· 23 items Β· integrity asserted by npm run build-plan:test