Decisions made without you — for review
Running list of calls I made on my own judgment while building "until all modules are built." Each line says what I decided and where it landed. Change any of them and I'll propagate. The 15 answered questions are in BUILD-PLAN.md §5 and are not repeated here.
Build & process
- Phase branches merge to
mainwithout asking once a phase's gate passes (overrides the usual "ask before merging" for this project only, per "make a decision… keep going"). - Migrations renumbered at merge when parallel agents collide (0011 publishing, 0012 admin_flags, 0013 links). Supabase keys by version, so numbers are never reused.
- Deploys are prebuilt from the workspace root (
scripts/deploy-app.sh) because the Vercel project root directory can't be set from this machine. Switching to git-connected deploys later is a one-time dashboard change. - Test timeouts raised to 240 s in integration suites so runs under heavy host load don't false-fail.
Product
- Redirect path is
/go/[code]on each product's own domain (HealthyApp's/r/[token]was taken). Short-link domain registration is still yours. - Click cookie: HttpOnly, SameSite=Lax, 90 days — matches the HealthyApp program's cookie window; the SDK reads the window from the API so other products can differ.
- HealthyApp seeded rates: 25% for 12 months (L1) + 5% lifetime (L2), 30-day hold, workspace minimum payout. Placeholder until you set real terms in Admin → Programs.
- Stripe webhook secret and payout account are env/config, not code; until the tellatribe Stripe account exists the Payouts screen shows an honest "payout account not configured" state rather than a fake run.
- Test-mode keys write
mode='test'rows that never enter the payable set, so you can exercise the full flow on live surfaces without creating liabilities. - Over-refunds are refused (
refund_exceeds_conversion) rather than driving a commission negative past the original. - Suspended affiliate's commissions go to
flagged(held visibly in the queue), not silently dropped. - Media pipeline (renditions) runs on Vercel functions with sharp + ffmpeg-static, bounded batches every 2 min; moves to a worker only if it hits the 250 MB function limit (agent R reports).
- Collateral bucket is private, 500 MiB cap, browser uploads go direct to Supabase Storage (TUS) — nothing passes through a Vercel function.
- HealthyApp integration is on a branch in vera-wellness (
affiliate-coach-integration) and never merged by me. SDK is vendored (copied), not published to npm. - Supabase Auth: magic links only, redirect allow-list limited to the two live app domains + localhost.
Portal (Wall · Links · Studio · Earnings)
- Mode 3 (teleprompter) ships in v1 as a "Record it" sheet — resolved script, read time, Copy script — not an in-browser camera. The phone's own camera app records; the sheet is the prompter. Full teleprompter UI is Phase 6c.
- Composer offers Instagram / Facebook / TikTok / LinkedIn at launch; X is not in the composer. X's open-balance + fee gating (decision 11) is enforced at the connection step, so it appears only once that flow is mounted.
- Disclosure check runs on the final edited caption, server-side; warns once, then "Post anyway."
- Wall billboard states beyond the brief: under-minimum ("rolls forward"), approved-but-no-payout-details (→ Set up payouts), negative net with the reason, and a three-step first-run inside the billboard.
- "Join to promote" cards link to
/join?program=<id>; the shell's join flow doesn't read that param yet — small follow-up in the shell. - Connect social account is a stub until the Ayrshare key exists (buttons show a toast). Publish-queue Retry is not offered to affiliates; failed posts show the reason and only queued ones can be cancelled.
- Ref links render as
/go/CODEby rewriting core's/r/in one place; the spoken form says "slash go slash". - Next payout date shown = first of next month (workspace
payout_scheduleis monthly for HealthyApp). - A test affiliate exists in the live database —
Test Affiliate, codeTESTAFF, email test-affiliate@affiliate.coach, enrolled in HealthyApp, no sponsor — so the live portal can be verified and screenshotted without waiting for a real signup. Suspend or delete it from Admin → Affiliates when real affiliates arrive (its codeTESTAFFis permanent per the immutable-code rule). - Joining a second product from the Wall is one confirmation screen (
/wall/join/<program>: rate, hold, exact terms version, "Join and accept the terms") — no admin approval step, same code and link, enrollment recorded with the current terms version like a first signup. Admin can still suspend the enrollment.
HealthyApp integration (branch affiliate-coach-integration in vera-wellness, not merged)
- SDK is vendored into
lib/affiliate-coach/(copied, header names the source commit) — no npm publish, no new runtime dependency. - Click cookies are widened to
.healthyapp.coach(like the session cookie) because a returning owner is paywalled on their tenant subdomain and a host-only apex cookie would be lost. Host-only on*.vercel.app/localhost. /go/is added to HealthyApp's middleware public-path list — without it every anonymous click would be bounced to sign-in before the redirect ran.- Metadata lands only in
startPlatformSubscription(the one subscription-mode checkout on the owner's own Stripe account). Card setup and Connect checkouts are untouched;client_reference_idis never used. - Affiliate links must point at
healthyapp.coach/go/CODE, not the marketing site (gethealthyapp.coachis a separate deployment). The product'sredirect_originis alreadyhttps://healthyapp.coach.
Media renditions (packages/media)
- 17 rendition cells across 7 platforms (Instagram feed 4:5 + 1:1, reel, story, carousel; Facebook feed + 1.91:1 + reel; TikTok; LinkedIn ×3; X 16:9 + 1:1; YouTube Short) plus a generic source and cover. Images are JPEG everywhere (not WebP — what lands in Photos and every network accepts); video is padded to the aspect, never cropped; sources longer than a cell's cap fail that cell with a plain message before any CPU is spent.
- PDFs get a generated cover card (title + page count), not a first-page render — no PDF rasteriser is available in the serverless build. The PDF itself is served as its own rendition.
- Failed renditions are not auto-retried (the failures are deterministic: too long, unreadable); re-enqueue from the admin regenerates them. The schema has no
errorcolumn yet, so the reason is only in the worker log — a small follow-on migration is recommended. - Storage: one private bucket
collateral, 500 MiB project ceiling, applied live (the bucket itself had to be created through the Storage API —supabase config pushpushed the limits but did not create it);/api/cron/renditionsruns every 2 minutes with a bounded batch and the ffmpeg binary traced into that one function only. - ffmpeg comes from
@ffmpeg-installer/ffmpeg(per-platform optional packages), notffmpeg-static, and pnpm installs linux-arm64 native packages beside the Mac's (supportedArchitecturesinpnpm-workspace.yaml) — because deploys are prebuilt on this Mac and Vercel runs the API on linux-arm64. Nothing changes for a future git-connected deploy. - Links are never deleted and only their label can change — a URL that is out in the world keeps working. Every enrolment mints one default link automatically; labelled links are the affiliate's own.
- Per-link counters are computed, not stored: a labelled link counts clicks carrying its slug; the default link owns every click no labelled link claims (incl. Studio's collateral-tagged clicks). "Visitors" = distinct device per UTC day (the IP hash is salted daily, so cross-day uniqueness is impossible by design).
- Short links are hidden until
SHORT_LINK_DOMAINis set (the domain is yours to register); the recruiting link useshttps://tellatribe.com/join/CODEuntilNEXT_PUBLIC_PORTAL_ORIGINsays otherwise.
Teleprompter (Mode 3, v1.5)
- Recordings save to the phone, not to Studio (share sheet on iOS → Photos; download elsewhere). Uploading takes into Studio is a later item; the seam is in place.
- Pace comes from the rendered script at 150 wpm, speed 0.8×–1.4×, 3-2-1 countdown, soft mark at 60 s, hard stop at 3 min; the spoken-disclosure paragraph is bolded and the scroll pauses 1.5 s the first time it reaches the reading band.
- Burn-in is chosen before recording (standby switch, default off, remembered per affiliate); when the browser can't composite it falls back to a plain take and says so.
- In-app browsers (Instagram/Facebook/TikTok/LinkedIn) get an "open in Safari/Chrome" card instead of a broken camera — they don't expose the camera to web pages.
- Route is
/studio/record/<asset>(the plan said/studio/<asset>/record); refusals render a reason card with a back link rather than redirecting.
Admin second half (Collateral · Payout runs · Reports · Audit · Settings)
- Publishing a collateral version requires three things the schema only documents: a disclosure block, a ticked claims attestation (recorded with who/when), and valid tokens; scripts must also carry
{{spoken_disclosure}}. Rolling back to an older version passes the same gate. - Payout release is three explicit clicks — Assemble → Check funding → Release — each audited; an empty assembly opens no run. Funding instructions (decision 9) show when the payout account is short.
- Support/sender email and the four fraud thresholds live in
workspaces.branding.settings(jsonb) rather than new columns, to avoid a migration collision mid-wave; a proper column migration is the better long-term home. The chargeback rule is still a constant in the webhook handler and the Settings page says so. - "Revenue" in reports = attributed live conversion amounts (never gross, never test mode). Cohorts key on first attributed payment per product × customer.
- Renditions in the admin come straight from the media package's SQL half (
@affiliate-coach/media/enqueue); the native pipeline runs only in the API cron. Admin uploads go browser → signed Storage URL, never through a function.
Publishing wiring (Connect → post)
- Migration 0014 lets a revoked social connection return to connected (0011 forbade every exit from
revoked, so Disconnect → Reconnect could never work). - Connect state is an HMAC token (30 min, bound to affiliate + network) signed with
CONNECT_STATE_SECRET, falling back to the service-role key — no new secret required. Ayrshare return URL to whitelist:https://tellatribe.com/studio/connect/return. - X appears in Connected accounts only when offered (program has X on and the affiliate's balance covers the fee) — absent otherwise, no greyed nag.
- The Composer's preview and the worker's outgoing payload come from the same
preview(), asserted by a test; nothing can post that wasn't shown.
Found while demoing
- A conversion timestamped in the same second as its click is treated as "before the click" and goes unattributed. Stripe reports whole seconds, our clicks carry milliseconds, so a checkout that completes within the click's second (or any clock skew between Stripe and our server) would lose attribution. Fixed Oct 5: the window check now tolerates a conversion up to 60 s before its click (
CLOCK_SKEW_TOLERANCE_MS), with unit tests. - Focused caption tiles overlap their own title on the Wall (the caption's first line renders as display text behind the title). Fixed Oct 5: the focused poster line clamps to two lines.
Creator Campaigns (decided by Jonathan, Oct 14 — recorded here for completeness)
- Open calls visible to every eligible affiliate on the wall.
- 10% brand-side fee on flat fees only; gifting free.
- tellatribe is the payer of record (one 1099 per creator).
- Instant payout creator-paid at 1%; standard run free.
- Drafts auto-approve after 10 business days of brand silence, on by default.
- No platform follower floor; the brief decides.
Payout tax compliance (Oct 19)
tellatribe files 1099-NEC, never 1099-K. It is payer of record (decision 57); affiliates receive transfers, not card sales. Overrides, flat campaign fees and gifted product (at fair market value) all count toward the same total. Threshold is a workspace setting seeded at $2,000 for 2026 (the post-2025 federal threshold, inflation-indexed), not a constant.
Tax identity gates payouts. Stripe Express collects W-9 / W-8 and TIN-matches at onboarding; an affiliate whose tax details are not verified has their balance held rather than paid. Rationale: paying an unverified TIN triggers 24 % backup withholding, which we will not operate. Launch is US-only for payouts; non-US recipients (W-8 on file, no 1099) are enabled country by country.
Stripe Connect is the launch payout rail; PayPal Payouts is a later second rail. PayPal is cheaper per payment ($0.25 flat domestic) and needs no recipient onboarding, but its standard API collects no tax forms and files no 1099s, requires an application PayPal can deny, and returns unclaimed email payouts after 30 days. Add
paypalto the payout-method enum only once our own W-9/W-8 collection exists.Stripe's Connect 1099 product does the filing (e-delivery free; $2.99 IRS e-file, $1.49 direct state file, $2.99 mailed, per Stripe's published page). Calculation method is payments by payout date; the admin gets a calendar-year paid-by-recipient report (paid minus recovered reversals) and runs it monthly so January reconciles to Stripe. Someone must click File in January — Stripe does not file on its own.
Every affiliate gets a calendar-year earnings statement in the portal regardless of threshold, and product copy never characterises income or gives filing advice ("consult your tax advisor").
Three Vercel projects (API, portal, admin), one monorepo, one database. Different audiences and risk: the portal is public and mobile-first; the admin requires membership + MFA and never shares a domain or session with the portal; the API hosts the Stripe webhooks, the redirector and the crons and must keep running through any UI deploy. Each has its own domain, env, deploy history and rollback. Consequence: the Stripe restricted key is read by all three, so it is set once as a Vercel team-level shared env var on
sales-bossand attached to the three projects; the two webhook secrets live only on the API project.
Built Oct 19 (tax identity): migration 0015 adds affiliates.tax_status / tax_country / tax_status_synced_at, mirrored from account.updated and live syncs; the payout run and the admin preview skip tax_identity_missing; Reports gains Paid by recipient · tax year (CSV + drill); the portal's Payout method shows the tax-identity state and Earnings → Payouts lists downloadable annual statements per calendar year.
Creator Campaigns build (Oct 19 → )
- Gift value is recorded on the deal, not as a commission row. A non-cash value must never enter a payout run; the tax report adds verified gift value per recipient to the 1099 total.
- A bounty is a commission row of kind
bountythat names itsdeal_task_id(one per task), born approved (or pending when the brief sets a hold of 1–7 days); its reversal (post deleted inside the retention window) names the same task. - Brand money is a per-brand balance ledger (
brand_balance_entries, append-only): funding in, escrow held at acceptance, bounty and the 10 % platform fee out at release. Balance = Σ entries; free to spend = balance − reserved. - Creator metrics come from the connected network's analytics through the existing aggregator, with manual entry as an unverified fallback. No new OAuth apps for v1; the app-review path for first-party demographics stays in the PRD's "later".
- The platform fee is charged to the brand balance at release, snapshotted on the deal (
platform_fee_bp) so a later price change leaves old deals alone. - Verification matches the post the creator links plus the brief's required tags/handles, via the aggregator's post history where connected; unverifiable formats are brand-approved with proof.
- Brands fund their balance through Stripe Checkout on the payout account (card or ACH), minimum $100, processing fees paid by the brand. So the payout account now accepts payments as well as sending them; a funding webhook (
/api/stripe/funding) on that account credits the ledger once per Checkout session. The manual bank top-up stays as the fallback for the house brands. - Eligibility is evaluated in the app, not the database, against
creator_profiles, so an ineligible brief can be shown dimmed with its one reason (PRD B.6) rather than hidden. - Non-Stripe publishers integrate through
POST /v1/conversions(click id or ref code + idempotency key; refunds as reversals), which already exists and is processor-agnostic; no-code connectors (Shopify app, WooCommerce plugin, webhook relay) are a later phase, not Part B. - Brand balance arithmetic: Σ entries is the free balance. Holds are negative entries, so the engine writes an
escrow_releasefor the consumed hold alongside everybounty+platform_fee; a hold and its spend never both subtract. Migration 0018 redefinesbrand_balances.reserved_centsas holds − releases over open deals (floored per deal);brandBalance()returnsfreeCents= Σ entries,reservedCents,balanceCents= free + reserved. - Migration 0018 widens two vocabularies the engine needs:
commission_events.sourcegainscampaigns(0005's check would have rejected the contract'saffiliate_coach.source = 'campaigns'and rolled back every verification) andfraud_flags.kindgainspost_deleted(0008 removed the catch-allmanualon purpose). - The escrow hold covers the flat fee only; the 10 % platform fee leaves the free balance at release (decision 71). A brand can therefore read slightly negative on fees until it tops up; the funding sweep funds nothing until then.
- The fee a deal pays, in order: the admin's explicit amount → the brand's counter the creator agreed to → the creator's bid → the brief's flat fee → zero. Gift value/description default from the brief's
gifting(value_centsormax_value_cents,description); a gift with a value needs a description.platform_fee_bpis snapshotted at 1000. - Acceptance enrolls only against published terms. When the brief's program has no
terms_version_id,accept()refuses withprogram_has_no_termsrather than writing an enrollment with no compliance record. - Facts for eligibility: wall sales = the creator's attributed
purchaseconversions in the workspace; an exclusivity runs from the last post (or the deal's creation while nothing is posted) +exclusivity_days; a brief conflicts when its own exclusivity categories or its eligibility categories intersect an open exclusivity; the reason names the brand ("you have an exclusivity with HealthyApp until Nov 2").creatorFactstakes an optionalproductIdto scopeenrolledInProduct. - Dispute decisions:
release→ the deal goes back toverified(all tasks complete) orin_progress;refund→ deal cancelled, remaining hold released;split→ a bounty ofsplitCentson the first task without one (approved, fee charged), the rest released, deal cancelled. An appealed dispute closes with its second decision (one appeal, PRD B.11). - One rights grant per deal, opened at the first verified task, starting at that post's
posted_at;rightsRemindersstampsreminder_sent_atand writes the audit row (mail is not wired). - Sweeps:
verificationSweeplooks only at tasks with apost_urlor apublish_job_id; a deleted post inside the usage window moves an approved bounty toflagged(flag_reasonmanual) and only flags (fraud_flags) a paid one, once per task. - Quality score v1 weights: engagement vs tier benchmark 40, reach/followers 20, comments-per-like 15, 30-day growth 15, audience-country match 10. An unknown input leaves the average and lowers the ceiling by 12, so an account with no data lands at 40 ("check"), never "good" by default.
- Task path:
reviewDraft('approve')leaves the task atdraft_approved;markPostedwalksdraft_approved → awaiting_post → posted_unverifiedin one call (no-draft tasks fromnot_started), and re-posts afailed_verificationtask with the fixed link. - The funding webhook resolves its workspace from the Checkout session's
metadata.workspace_id(checked against the table), falling back to the only workspace when exactly one exists, like/connect;metadata.product_idis written on the event row only when that product exists in that workspace — so a mis-metadata'd session is still ledgered (ignored:missing_ids) instead of failing the composite FK. - A funding event that carries
accountis refused asconnected_event_rejected(400) but still written tostripe_webhook_events— the exact inverse of/connect'splatform_event_rejectedguard; the two endpoints never read the same event two ways. - ACH top-ups are two events, one entry:
checkout.session.completedwithpayment_status = 'unpaid'is ledgeredok:pendingand writes no balance entry;async_payment_succeededwrites it. A paidcompletedfollowed by anasync_payment_succeededfor the same session funds once (brand_balance_entries.stripe_refunique →ok:duplicate). async_payment_failedandcharge.refundedare audit-only (audit_logactor_kindwebhook, actionsbrand_funding_failed/brand_funding_refunded, objectproducts); the admin reverses a refunded top-up by hand withrefundFundingafter checking what the brand still owes./api/cron/campaignsruns the seven sweeps per workspace with each step isolated, reporting counts, a per-steperrorslist and the post-lookupmode:aggregatorwhenAYRSHARE_API_KEYis set, elseurl_only(found = typed post_url present). In aggregator mode an Ayrshare error for one task falls back to url_only for that task — the aggregator being down must never read as "post deleted"; the paid-partnership label is alwaysnullbecause/historydoes not expose it.packages/campaignsin the API branch is a contract stub (header "STUB — replaced by the engine stream at integration"): the exact §1.3/§1.5 signatures the API calls, with onlyrecordFundingimplemented (on-conflict onstripe_ref). The integrator drops it for the engine stream's package; all API logic lives inapps/api/lib/{funding,campaigns-cron,post-lookup}.ts..env.exampleis force-added (the repo ignores.env*, yet AGENTS.md names the file as the list of every variable an app reads); it documentsSTRIPE_FUNDING_WEBHOOK_SECRETbeside the other API secrets with no values.- (admin) Campaign queue items open the campaign detail, not the affiliate drawer.
dispute,failed_verification,unfunded_dealandweak_applicantlink to/campaigns/[id]#section;loadExceptionreturns null for them, so the drawer code stays untouched. - (admin) "Pending settlement" on the balance strip is inferred from the event ledger: a
stripe_webhook_eventsrow with endpointfundingand typecheckout.session.completedin the last 5 days whose session id has nobrand_balance_entries.stripe_ref— no new column, no admin write. - (admin) A creator's own deal on the brief being evaluated never counts as a clashing exclusivity.
evaluateEligibilitygets the facts minus that campaign's deals; otherwise every hired creator would read ineligible on their own campaign. - (admin) The gift column on Paid by recipient also lists gift-only recipients (a deal verified in the year with no payout sent) and the 1099 threshold test uses paid + gift, since the gift alone can cross it (decision 61/67); the Stripe Tax-reporting total will be short by the gift value, and the page says so.
- (admin) Weak applicant = an open application whose creator has a
weakprofile and nogood/checkone; a creator with mixed words is not queued (the ranking already shows the word). - (admin) Exclusivity windows for eligibility run from the deal's start until
exclusivity_daysafter the campaign's last post window (or the deal's creation when no window is set) — the admin's own read; the engine'screatorFactsis the authority at integration. - (admin) Unverifiable = podcast, newsletter, live or custom formats, or the newsletter/podcast/other platforms; only those show "Approve as delivered", everything else waits for the sweep (decision 72).
- (admin) The brand-funding Checkout line item is a one-off
price_datanamed "tellatribe balance ·" with , so the funding webhook can refuse sessions that are not top-ups.metadata.kind = 'brand_funding' - (admin) The report's attributed sales use the campaign window
published_at → closed_at|nowand only creators with a live (non-cancelled) deal on the campaign; the campaign must have aprogram_id, otherwise the report says "no program" rather than inventing attribution.
Portal stream (creator side), Oct 19:
97. Eligibility facts are built in the portal from the creator's own my_* rows (profiles, enrolment from the context, completed deals, wall sales, exclusivities from open deals' campaigns) and handed to the engine's evaluateEligibility; the service role reads only the exclusivity terms of campaigns behind open deals, which my_campaigns withholds once a brief closes.
98. The open-call tile's tint is the product mark colour mixed 45 % into the ground (color-mix), so the brand reads on the tile without product colour touching chrome; no mark colour → the slate tint.
99. Campaign and deal server actions return { ok, message } instead of throwing, because Next masks thrown messages from server actions in production and a CampaignError's message is the sentence the sheet must show.
100. Post-for-me from a deal records the post link lazily: enqueue first, then markPosted(external_url, publishJobId) the moment the job carries a URL; until then the task shows "A post is queued from here" with a "Record the link when it is live" control, and Copy & post stays available. No engine function links a job to a task before it posts.
101. The post window is enforced in the portal's own preflight (lib/studio/window.ts), joined to the network preflight in previewPublish and re-checked in enqueuePublish, rather than in the publishing package — the package knows nothing about campaigns.
102. Draft files go to the collateral bucket at {ws}/deals/{deal}/{task}/{ts}-{filename} through a signed upload URL minted server-side after my_deal_tasks confirms the task is the creator's; the storage path, not a URL, is what submitDraft records.
103. Bounty rows take their words from my_deal_tasks.bounty_commission_id (the 0016 footer's route) plus my_deals / my_campaign_deliverables, with campaign title and product name from workspace-scoped service reads; a bounty with no reachable task still renders as "Piece for a brand · verified … · pays …" rather than failing.
104. The Studio tab count adds deals whose next action is the creator's (a funded deal awaiting signature, or an in-progress deal with a task in a creator status); an unfunded deal's not-started tasks never count.
105. "Something wrong?" is offered while a deal is in progress or delivered, and for 7 days after a verified/paid deal's completion; one appeal per decided dispute. Inside the engine's openDispute/appealDispute; the portal only gates the controls.
106. The reputation line calls the engine's creatorReputation and hides on any failure or zero deals; the portal computes nothing of its own there.
Still yours (not decided, blocking nothing)
- Create the tellatribe Stripe account →
STRIPE_SECRET_KEY,STRIPE_CONNECT_WEBHOOK_SECRET,STRIPE_FUNDING_WEBHOOK_SECRET; enable card + ACH payments (brand top-ups) and Connect → Tax reporting (1099-NEC) per the Stripe guide Parts 1, 5 and 6. - Create the HealthyApp revenue-account webhook →
HEALTHYAPP_WEBHOOK_SECRET; setproducts.stripe_account_id; set the threeAFFILIATE_COACH_*env vars on the HealthyApp Vercel project; mergeaffiliate-coach-integration(full checklist in that branch'sINTEGRATION.md). - Ayrshare API key (
AYRSHARE_API_KEYon the portal + API projects; whitelist the return URL above) — the only thing between Connect and live posting; domain for short links (SHORT_LINK_DOMAIN); real program rates.
Rendered from DECISIONS-FOR-REVIEW.md. Edit the source, not this page.