Section one
The one-page map
Two lanes and one bottleneck. Everything upstream of the canonical tables is run by a named operator from a terminal. Everything downstream is automatic and reads through a single assembly function. Nothing crosses that line by accident, because no route under apps/web imports the import, mapping or derivation modules, and a verifier pins that at source level.
Every engine keeps a triple gate: dry run off, the stage's own approval flag stated, and SHOPIFY_IMPORT_LIVE_ENABLED=true. Any missing condition turns the run into a plan. A plan prints before it reads, so an unreachable database gives you a plan and a calm stop rather than a stack trace.
Session entry point getSessionWeeklyDigestContext is request-memoised with React cache, so a page that reads it three times pays for it once and cannot fork. scripts/verify-digest-parity.mjs pins every consumer to this module at source level.
Hover or focus any stage Each stage names what it writes, what it proves, and which flag it needs. Keyboard: tab through the boxes.
Side loop: the Monday cron
/api/jobs/weekly-snapshotat0 20 * * 0UTC, which is 06:00 Monday Brisbane. Writes the week's row intometric_weekly_snapshots./api/jobs/weekly-digestat0 21 * * 0UTC, which is 07:00 Monday Brisbane. Builds the digest from the same assembly and sends.- The snapshot job also closes out any live play whose readout clock has run out, writing the readout summary the board and the report both read.
Side loop: the play lifecycle
- proposed on the board, ranked by
buildTopMoves. - briefed, and from that moment excluded from the ranking so it is never re-asked.
- live, with
holdout_pctrecorded when the team ran the holdout. - readout, then learned. Both sit in the readout column.
- declined is the fifth path and occupies no column: off the list, off the ranking for 90 days, with a dated notice.
Why the operator lane cannot be reached from a browser
It is not a permission check that could be misconfigured. It is an import graph. No file under apps/web imports packages/shopify/src/import-pipelines.ts, packages/shopify/src/raw-to-canonical-runner.ts or packages/shopify/src/derivation-job.ts, and scripts/verify-shopify-operator-cockpit.mjs asserts that at source level. The Settings import panel therefore states where the data is up to rather than offering a control that cannot start anything.
Three further properties the same verifier holds: each tool refuses with no arguments and exits nonzero; each tool in plan mode against an unreachable database prints its plan, stops calmly and echoes no secret; and the Import runs section reads batch and error rows only, with no payload or error-context field anywhere near it. Data Health shows the safe operator message for a failed record and nothing else: no raw payload, no error context, no Shopify identifier.
This is the answer to the security question a CEO's CTO will ask on the call, and it is worth giving in exactly these terms: the app has no code path that could start an import, so there is nothing to compromise.
Section two
The data journey
Three staged commands, three separate approvals, and a ledger in the middle that has to balance to zero. Approving a live raw import is not approving mapping, and approving mapping is not approving the numbers a client is about to read.
Stage one: what the raw import writes
The command names a client id, a shop domain, the families to fetch and an order window. It writes one raw table per family and one batch row per family, and it reports per family the batch id, status and requested, imported, failed and skipped counts, plus pages read and Shopify request counts so a run truncated against the page cap shows itself.
Preflight is the part that stops the mistakes. It checks that the client row exists, that exactly one store row carries the requested .myshopify.com domain, that the connection is recorded as connected with a verified credential, and that the credential reference resolves. The order window is required for the customers and orders families, and a window wider than 31 days needs --wide-window-approved on top, because a first run uses the smallest window that still proves the path.
--notes="..." records a short operator note against the run: the primary batch in stage one, the mapping record in stage two, the run record in stage three. Use it to name the call or the approval the run belongs to, so the record in the database says why a run happened and not only that it did.
Stage two: what the ledger proves
The mapping plan prints the review ledger, and the ledger is the honesty mechanism of the whole pipeline. Read it in this order.
- Unaccounted rows must be zero. Unaccounted is rows read that the ledger cannot place in any other column: not mapped, not written, not skipped for a stated reason, not orphaned. Zero means every raw row is accounted for by name. Anything other than zero is an unexplained gap, and the tool exits nonzero on it. Do not apply on a nonzero unaccounted count.
- Skips, with the mapper's own reason. Every skip carries the reason the mapper gave rather than a reason inferred afterwards. Read each reason out loud and decide whether it is expected for this store.
- Orphans. One orphan is one unresolved child-to-parent link, so an order line whose product and whose order both fail to resolve counts twice. On a seven-day order window some orphans are expected, because a customer or product created before the window was never imported.
- The six link-quality counts. These are what Data Health later reports as customer and product link quality.
- The revenue tie-out. Orders, gross revenue, discounts and refunds, raw against canonical, with the difference. This should tie exactly, because both sides are the same rows counted twice. A difference here is a mapping fault, not a boundary effect.
- The issue codes. Any code present goes into the Issues table with a severity.
--mapping-approved is you stating that the raw counts have been compared against Shopify and that mapping is approved for this client and this store. Without it, --apply refuses.
Stage three: what derivation builds
Derivation turns canonical rows into every derived snapshot table: the weekly scoreboard series, the LTV driver rows behind the best-and-worst money read, the cohort tables and the rest. --as-of is the deterministic anchor and defaults to today; set it explicitly so a re-run later in the week reproduces the same numbers. --tables=<list> narrows the run; a first run uses all of them, in write order.
It reports per table the input rows, rows built, written, inserted, updated and skipped with a reason, then the weekly-series decision, then seven story invariants with pass or miss and the measured value, then the run summary.
Say this out loud before anyone reads the invariants. They are the demo story's bands, calibrated against the seeded demo tenant, and a real client will miss most of them. A miss is not a fault and nothing is gated on it. They are reported so that a wildly implausible derived number shows itself. What matters is whether the measured value is plausible for this store, and on seven days of orders the trailing-30-day and matured-cohort invariants cannot be meaningful at all. Record the misses as expected.
The weekly-series bootstrap rule
This is the one rule in the pipeline most likely to catch you out on a call, so it is worth stating precisely.
The ruleThe derivation job seeds
metric_weekly_snapshotsonly for a client that has no weekly rows at all, which is the state straight after a first import. Every later run reports the series as left alone, because the Monday cron owns the live series and two writers with two definitions produce a scoreboard that contradicts itself week to week.
The trap: a client can hold weekly rows that carry no information. Wisdom Foods holds five metric_weekly_snapshots rows written by the Monday cron on 2026-07-18, all with a null value, verdict needs_data and sample size zero, at snapshot date 2026-07-13. While those five rows exist the bootstrap skips, the historical weekly series is never written, and the client's scoreboard trend stays empty even though the canonical data behind it is real.
The recommendation on the call is to delete exactly those five rows by id, scoped to the client, so the bootstrap sees a client with no weekly rows and writes the true series. The client_id predicate stays even though the ids are unique, five ids and no more, and the expected result is exactly five rows removed. If the count comes back as anything other than five, stop and review rather than run it again. Record in the approval record whether it was approved, who approved it, the five ids and the row count removed.
If you decline, stage three still runs and the weekly series is reported as left alone. That is a caveat to carry into the client-facing gate, not a failure.
Where the Wisdom Foods first run actually stands
Nothing has been imported. The call sheet is written and copy-pasteable, and section A is fully open. These are the blockers, and the first four make stage one refuse.
| Open item | State | Whose hand |
|---|---|---|
| The real Admin API token | SHOPIFY_TOKEN_SECRET_REF and SHOPIFY_API_VERSION exist in Vercel production as empty placeholders. Two things are needed: the token value in its own server-side variable, and the ref set to env:VARIABLE_NAME naming that variable. | Marie, then Ben |
SHOPIFY_CONNECTION_FLOW_ENABLED | Not set in Vercel production at all. Set it to true and redeploy. Without the redeploy the new variables are not in the running build and the connect control stays disabled. | Ben |
| The store connection | shopify_connections reads status not_connected, auth mode custom_app_token, token configured false, token last verified null, API version null. Connect and verify from Settings on an agency seat. | Ben |
stores.status | Reads pending. Does not block preflight; it is the honest signal that this store has never carried data. Note it on the call. | Note only |
| The approval record | docs/client-builds/wisdom-foods-first-import-dry-run.md is a draft. Approval date, approval source and the exact date range get filled by Ben; the go or no-go decision stays blank until the run ends. The runbook forbids proceeding on an implied approval. | Ben |
Migration 202607260001 | supabase/migrations/202607260001_play_holdout_and_decline.sql is written and unapplied. It adds holdout_pct and widens the lifecycle status check constraint to accept declined. Until it is applied by hand, holdouts cannot be recorded, so every readout badge reads vs baseline, which is the honest state. | Ben |
The token is the one item that is not yours: it comes from Marie. Everything else on that list is a hand you have not yet put on the keyboard. Two independent things are outstanding at once, so do not treat "waiting on Marie" as the only reason the first run has not happened.
The three gates on the call, and what each one means
Go gate one, before stage two applies. Every batch completed or completed with documented non-critical errors, the reconciliation table filled in with differences explained, failed records reviewed, and mapping approved for this client and store. Record the approval date in the Mapping Approval table.
Go gate two, before stage two applies live. Unaccounted is zero, the revenue tie-out ties, and you say go.
The client-facing gate, before anyone outside the agency sees a number. Review in this order on an agency seat, against Shopify admin for the same window: /data-health with the Import runs section first, then /home, /moves, /retention, /acquisition, and /reports last because an export leaves the building. If the data quality rating reads Limited or Not ready, everything downstream stays internal-only. Every surface must show its sample sizes, its filters and its caveats; any surface that cannot honestly carry a caveat stays internal until it can.
Local operator environment, and the two known gaps
The three CLIs run from the repository root and read apps/web/.env.local without overwriting anything already exported, so a shell value always wins. Two gaps in that file, both handled by exporting names in one shell that stays open for the whole call:
apps/web/.env.localholdsNEXT_PUBLIC_SUPABASE_URLbut notSUPABASE_URL. The CLIs readSUPABASE_URLand never the public variable, so either export it or pass--supabase-url=<url>on every command.- The Shopify credential reference is not in that file either. Stage one needs
SHOPIFY_TOKEN_SECRET_REFand the variable it names.
SUPABASE_SECRET_KEY is already in that file and needs no export. Use --environment-file=<path> to point at a different file, never --env-file, which is Node's own flag and not what these tools read. Stages two and three need no Shopify credential at all; they read Supabase only. SHOPIFY_IMPORT_LIVE_ENABLED=true is set per command, not exported. Close the shell at the end of the call so the exported token value does not outlive it.
If the token is ever exposed, in chat, a screenshot or a log, revoke it in Shopify immediately and mint a fresh one. Tokens are free.
Rollback order, if the first run has to be unwound
Kill the credential first: revoke or uninstall the Admin API token in Shopify admin, set SHOPIFY_CONNECTION_FLOW_ENABLED to false and redeploy, then clear the variable named by SHOPIFY_TOKEN_SECRET_REF.
Then unwind data, children before parents, every statement scoped to this client's client_id and store_id and to the batch ids from stage one: raw_shopify_order_lines, raw_shopify_refunds, raw_shopify_discounts, raw_shopify_orders, raw_shopify_variants, raw_shopify_products, raw_shopify_customers, then the canonical tables, then the derived snapshot tables.
Rules that do not bend: never an unscoped delete in the shared project; preserve shopify_import_batches and shopify_import_errors unless you explicitly approve deleting logs, because the logs are the evidence; preserve the issue records and the client's dry-run record; never touch shared seed or demo data; and record who approved cleanup, which batch ids and tables were affected, which counts were removed and whether logs were preserved.
Section three
One truth by construction
The email, the scoreboard, the moves board, the monthly report and the analyst cannot disagree, and the reason is architectural rather than editorial. There is one place each shared thing is decided, and a verifier that fails the build if a surface starts deciding it privately.
One assembly
getWeeklyDigestContext is the single function that assembles anything about "this week". The home hero, /moves, the reports digest hero, the monthly report and the Monday email (both the cron route and the test-send action) all consume it. No surface can assemble its own variant with an input missing.
One move selection
selectMoveOfTheWeek is called once, inside that assembly, over the already-filtered ranked list. The home hero, the digest and the report read the result rather than re-deriving it, so the three can never name different moves.
One dollar-rounding path
Every model in dollar-models.ts returns a conservative-to-base range through the same round, and every surface formats it through formatMonthlyRange or the shared whole-dollar formatter. Two screens cannot round the same figure to two different numbers.
One cohort sentence
The cohort money sentence is generated from one template and appears on six surfaces. It is never rewritten locally, which is what stops a page inventing a share claim the arithmetic does not support.
One band-rule sentence
bandRuleSentence(tolerance) builds the verdict definition from the tolerance actually in force, states all four words a pill can show, and adds the mixed-bands clause when a client has per-target bands. There is no hardcoded copy of it anywhere.
One owner vocabulary
OWNER_SENTENCE_CLAUSE and OWNER_SENTENCE_NOUN hold the two grammars client copy needs, so the hero sentence, the email's owner line, the analyst's answer and the printed report all name the same people the same way.
The verifier that holds it: verify-digest-parity
scripts/verify-digest-parity.mjs is the one to name in a room. It pins every consumer of the weekly context to that module at source level and checks the ordering, so a future edit that quietly builds its own ranked list fails the run rather than shipping a fork.
The incident it exists because of. The cron email once named a different move at different dollars than the app. Nothing was wrong with either calculation. The cron built its ranked list without the lifecycle exclusions, so the briefed play stayed in the ranking, pushed the metric-matched play out of the top-five cut, and the email led with a different move. That is the class of bug the whole one-truth architecture is aimed at, and it is worth telling as a story: it is the most credible thing you can say about why the numbers agree.
Behind it sit 143 verifier scripts under scripts/verify-*.mjs, run by scripts/run-verifiers.mjs through npm run verify. They are not unit tests of arithmetic. A large share of them pin copy strings and source-level structure: that a rule sentence is generated and not duplicated, that a banned word appears in no client-facing string, that a surface imports the canonical module rather than querying directly. That is why a copy change is an engineering change here.
The two guards that stop a stale schema forking a number
The deployed schema is applied by hand, so a column the code expects may not exist yet. Two conventions handle that without letting a page and an inbox disagree.
Star selects, fields read code-side. play_lifecycle and ltv_driver_snapshots are read with select("*") on purpose. Naming holdout_pct in the select would fail the whole read and empty the board, rather than simply returning null for one field.
The finite-number guard. A star select on a project whose migrations trail the repo hands back undefined for a newer column, and a null check does not catch it. Every numeric field goes through withFiniteNumberFields with LTV_DRIVER_NUMERIC_FIELDS. The pages already priced their plays off guarded rows while this assembly once priced the Monday email off raw ones, which is exactly how a page and an inbox fork on one bad row.
Unresolvable rows are excluded and logged, never rendered. A play_lifecycle row whose stored play_key cannot resolve is dropped with a console warning naming the raw key. It is a data bug to fix at the source, not copy to launder into a card title.
Section four
Every rule, exactly
These are the certified wordings. Where a sentence is quoted here it is the sentence in the code and in the client copy, and changing it means changing a generator and a verifier. Learn the derivations underneath, because the derivation is what survives the follow-up question.
1. The band rule
Certified wording"On track means within {t}% of target. Watch means {t}% to {2t}% off. Off track means more than {2t}% off."
"Ahead means more than {t}% past target."
Default t is 5, so the bands are 5 and 10, not 5 and 15. Per-target tolerance is possible: a client can set a wider band on one target, and when bands differ a mixed-bands clause renders, saying that the wider target reads against its own band the same way. The sentence quotes the tightest band in force.
Five pills exist: ON TRACK, AHEAD, WATCH, OFF TRACK, NEEDS DATA. The sentence is generated by bandRuleSentence(tolerance) from the tolerance actually in force, never hardcoded, and it states all four verdict words because stating only the on-track half left the other three undefined on the surfaces that hand those words out. A hero line can take the first clause alone via onTrackOnly, off the same tolerance, so the short line and the fold can never disagree.
It is stated on-page in the "How these numbers are computed" folds and the tile folds, and it is readable in the fold on touch devices, which is the accessibility requirement that stopped the rule living only in a hover.
2. The two-family overlap haircut
Certified wording, pinned in code and in a verifier"The 25 percent overlap haircut applies to a roll-up if and only if two or more of its plays sit in the same overlap family, either the monthly intake family (second-bottle-countdown, reorder-reminder, running-late, winback-window, rescue-list) or the prospecting budget family (budget-shift, spend-pullback); otherwise the roll-up is the plain sum."
Every roll-up's prose branches on haircutApplied, and there are exactly two branches:
Haircut branch"These plays work the same customers, so the combined figure carries a 25 percent haircut."
Plain-sum branch"These moves work separate audiences, so the combined figure is a plain sum."
Page-local noun variants exist and are deliberate: "the total" on retention and acquisition, "the combined range" on moves. Every combined dollar carries the "modelled, before testing" chip.
Why it matters: before this rule the haircut applied to every roll-up, so a page showing one reminder play beside a founder-owned discount kill quietly shaved 25 percent off two figures that share no customer at all. A play in no family is additive. Two plays in different families are additive too. The function returns haircutApplied and overlapFamilyCount so page prose can stop claiming a haircut that did not happen.
The deck needs a text fix. The deck's four illustrative moves are the reminder (intake family), the Daily Greens budget shift (budget family), the win-back at day 45 (intake family) and retiring WELCOME20 (no family). Under the real rule the haircut fires if and only if both the reminder and the win-back are selected, the only same-family pair among the four. The deck's M1 plus M3 gating is therefore behaviourally correct; its RULE text is the thing that must be updated to the two-family sentence above.
Why those two families and no others
Family one is the monthly intake of first-time buyers. The second-bottle countdown prices the same intake the reminder does, because both work customers inside their first supply period. The late nudge, the win-back and the rescue list all work the one-and-done pool that intake feeds. Two of them in one roll-up price the same money twice.
Family two is the prospecting budget. Shifting spend toward the best money and pulling it back from the worst move the same dollars, so summing both at full value spends that budget twice.
The count is the size of the largest single family represented, not a total across families. Counting across families would discount a reminder and a budget shift that share nothing.
There is also a legacy identity-free overload of rollUpWithOverlapHaircut, kept so surfaces written before the rule still compile. With no play keys it cannot know whether the family overlaps, so it keeps the old unconditional haircut: understating is the safe failure.
3. Move of the week: the selection order
Certified wording"Worst off-track measure by ANNUAL dollar gap picks a matched play from the ranked list; if none matches, the highest-value ready move leads. A Watch measure takes the headline ONLY in a week where nothing at all is off track."
Read as the code runs it:
- Take the measurable metrics, meaning those with both a value and a target.
- Among those off track, sort by annual dollar gap descending and take the first. Only if that set is empty, do the same among those on watch.
- For that headline metric, walk its preference list of play keys in order and take the first play present in the ranked list. That is the move of the week, and
matchesMetricis true. - If nothing in the preference list is present, the highest-value ready move leads and
matchesMetricis false. A fallback move's dollars must never be quoted as the price of fixing the metric.
All five dollar gaps are on one annual basis. This ranking was previously distorted by mixed bases and is fixed. The ranked list itself is buildTopMoves: lifecycle plays plus acquisition plays, in-flight keys excluded, sorted by the high end of the dollar range, cut at five.
The metric to play preference map, verbatim
| Metric | Plays that genuinely answer it, in order |
|---|---|
revenue_30d | None. Revenue is the outcome, so it always falls through to the highest-value ready move with matchesMetric false. |
repeat_rate_90d | reorder-reminder, winback-window, rescue-list |
second_purchase_rate_60d | second-bottle-countdown, reorder-reminder |
ltv_cac | subscription-moment, budget-shift, spend-pullback, discount-kill |
median_days_to_second | reorder-reminder, running-late |
The agency-owned play stays first in each list so today's routing does not change where it is available. The rest exist so a spend gap is never answered by an unrelated fallback move: the ratio moves either by raising value per customer (the subscription pitch) or by buying better customers and stopping the margin leak (the budget plays and the code kill).
4. The cohort projection sentence, and the phrase that was banned
Certified wording, locked across six surfaces"Twelve months of intake at that cohort's rate is worth about {$X}."
Revenue fold"Held for a year, that drop is about {$X}."
Timing fold"...worth about {$X} a year, modelled on the reminder flow before testing."
Never "of that gap." The reason is arithmetic, not taste. The figure is a projection: it takes the worst cohort's rate and asks what twelve months of intake at that rate would be worth. It is not a share of anything. The worst cohort's rate multiplied by twelve months of intake can exceed the whole annual gap, because the gap is computed against the blended rate across all cohorts while the projection runs the worst rate across a full year of volume. A sentence saying "of that gap" would then claim a share above 100 percent, and a CEO who checks the arithmetic finds it in about ten seconds.
Say it as a projection out loud too: "if every month looked like your worst cohort, a year of intake at that rate is worth about this much." That is defensible. "That cohort is costing you X percent of the gap" is not.
5. The holdout, and the difference in differences
The brief specifies a 10 percent holdout. This is real measurement, not a label.
When a holdout was run and recordedBadge: vs holdout. Note: "Read against the holdout the brief specified."
When no holdout was recordedBadge: vs baseline. Note: "Movement is observed alongside the play, not proven by it."
Those two strings are the only two, held in one module as READOUT_NOTE_HOLDOUT and READOUT_NOTE_OBSERVED, selected by one function, readoutNoteFor(holdoutMeasured). The board, Data Health and the monthly report cannot phrase the same readout differently.
The mechanics, stated exactly:
- A readout is holdout-measured only when both holdout points are readable, the held-back group's value at the play's baseline point and at the latest point. A
holdout_pcton the row says a holdout was specified; only the two figures let anything claim the movement was read against it. - When both are readable,
observedDeltabecomes the difference of the two movements, treated minus holdout. The held-back group's movement is subtracted before any verdict is formed. - A dead-even readout therefore reads flat. If the market moved and the holdout moved with it, the play gets no credit for it.
- Every surface reads
observedDelta, so all of them inherit the subtraction by construction. The win verdict and the app's single celebration are judged on the subtracted figure, through the same steady-movement rule the scoreboard uses.
Today's honest state. Migration 202607260001 is pending, so holdout_pct is not in the deployed schema and a holdout cannot be recorded. Every badge currently reads vs baseline with the observed-alongside note. Do not describe holdout readouts in the present tense on a call until that migration is applied. Describe the mechanism, and say the first holdout lands with the first real play.
6. The decline path
Certified wordingMenu item: "Not this week". Confirm: "Take this move off the list? Next Monday brings the next-highest move instead."
Dated notice: "{title} is off the list until {date}; next Monday brings the next-highest move."
A declined move leaves the board, a visible dated notice renders, and the play is excluded from the Monday ranking for 90 days. A no is a decision, not a forever, and 90 days matches the readout cadence, so the quarterly re-look puts the move back in front of the person who passed on it once the numbers behind it have moved. Nothing re-asks a question that was answered last Monday.
Drag can never decline. Only the menu can. A destructive decision needs an explicit confirm, and a drag gesture is not an explicit confirm.
Two implementation details worth knowing before someone asks
An undateable decline holds. If a decline's date cannot be parsed, isDeclineActive returns true. A no whose date cannot be read is honoured rather than quietly re-asked.
A decline is recorded twice, on purpose. Status declined is authoritative and needs the status check constraint widened in the deployed schema, which is what migration 202607260001 does. Until then the write path records the decline in the brief jsonb under the key declinedAt, which needs no schema change, and the read treats a proposed row carrying that marker as declined. Once the constraint is widened both agree and the marker simply dates the decline.
A declined play maps to the proposed stage. It never reached a later column, so every "stage is not proposed" reading elsewhere keeps telling the truth: a play that came off the list is not in flight. The board drops it by status, so it occupies no column at all.
7. The reorder clock
This is per client, not a house constant. The product reads the client's own replenishment_cycle_days when it is set, and defaults to 30.
| Thing | Rule | On the default 30-day clock |
|---|---|---|
| Working cycle C | The client's replenishment cycle, always the supply clock | Day 30 |
| Target day | Cycle plus 3 days grace | Day 33 |
| Reminder fires | Cycle minus 5, computed as cycle minus the larger of 4 and 15 percent of cycle | Day 25 |
| Reminder play window | Day 21 to day 33, matching the strip | Day 21 to 33 |
| Strip zone: Too early | Up to the window opening | To day 21 |
| Strip zone: Ideal reminder | The window | Day 21 to 33 |
| Strip zone: Slipping | Past target, not yet lapsed | Day 33 to 45 |
| Strip zone: Late win-back | Lapsed | Day 45 on |
The strip states which zone the median lands in, in text and in the aria label, not by colour alone.
The rule under the rule. The working cycle is the supply cycle, full stop. An observed median of 38 days against a 30-day supply is the gap being closed, not the new clock. Anchoring timings to the median would bless the failure it measures. Success metrics read "moving toward day 33", never toward the observed median. Only the running-late nudge fires on the median, because catching the already-late is exactly its job.
8. The materiality floor
15 percent. A best-or-worst money gap only moves budget once it clears 15 percent of the baseline; below that the honest verdict is hold. It is one exported constant, MATERIALITY_FLOOR = 0.15, shared between the play builders and the acquisition surface so a play and a row can never disagree on materiality. A gap that fails the floor prices at nothing and never ranks.
9. The sample floor
n at least 25. HEADLINE_SAMPLE_FLOOR = 25. A cohort or segment below it cannot carry a headline; it renders as NEEDS DATA or as a limited-evidence row. This is also a hard gate in the CEO audit: a currency or numeric render that can show an n under 25 as a headline is a fail. On a seven-day first import most things will sit under the floor, and the app saying NEEDS DATA is it working, not it broken.
10. Margin versus revenue labelling
The two bases are never mixed silently and never unlabelled. The canonical read reports its own basis, and the phrasing follows it.
- Default gross margin is 0.65 where a margin figure is modelled.
- Play dollars and the cohort bleed read "in margin", or "in margin after product costs" in longer form.
- Cohort realised value and the acquisition money rows read "in revenue before costs", and the acquisition copy says so explicitly when the panel above it is a margin figure: the money rows read in revenue before costs, so they run higher.
- When any cohort in the blend is read in revenue, the whole blend becomes a revenue figure and the label changes with it. The two figures differ by design, and any surface that shows both says which is which.
11. The price-freeze rule
The rule, as the code states it"One priced-play truth on the board: a card shows the figure stored when the play was briefed, and only switches to today's pricing when the two disagree beyond rounding, saying so in the note."
The note is one of exactly two strings: "priced when briefed 14 July 2026" with the real date, or "repriced against this week's numbers". Without the freeze, a briefed play's number drifts every week as the inputs move, and a client who screenshotted the brief finds a different figure on the board. With it, the number a brief was approved at stays the number, and a genuine change announces itself.
12. One clock per surface
Each artefact carries exactly the time anchors it needs and no more. The monthly report carries two: the report month and one data-read line. A closed month that predates snapshot history says "Rebuilt from current data on 25 July 2026" rather than implying it was captured at the time. Every date a report shows is human-formatted, never a raw timestamp. The export adds only the generation date and the freshness dates, nothing else. Day 33 is the single reorder target named in the retention hero story, and the second-purchase tile marks its twin dollars as the same gap read as a rate rather than as a second opportunity.
Targets: which three the client sets and which three you set
The wizard sets three, each previewed live against its own band: repeat rate, second-purchase rate, monthly revenue.
Three are product-set, standing figures for consumables until the strategist changes them with the client: the reorder day (from the supply clock), payback (day 60) and LTV to CAC (3.0x).
A saved reorder target looser than the clock is set aside with a named figure, so a client cannot quietly lower the bar below what the supply cycle implies without the app saying which figure it is using instead.
Where no second-purchase target is set, the fallback is the good-band midpoint by average order value: 0.375 under $40 AOV, 0.36 from $40 to $80, 0.34 above $80, and 0.36 when AOV cannot be read.
Owner vocabulary, in the exact sentence positions
| Owner | Clause, after a move | Noun, after a label | Chip |
|---|---|---|---|
| Agency | "with Click Catalyst" | "Click Catalyst" | CLICK CATALYST / Email + SMS |
| Client | "with your paid ads team" | "your paid ads team" | YOU / Paid ads |
| Founder | "yours to call" | "yours to call" | FOUNDER / Your decision |
The founder wording is deliberately the same in both positions: a founder-owned move is a decision, and "yours to call" reads correctly after either lead-in. The report decision uses "and the call is yours".
Mixed splits are real. Founder and paid-ads moves rank on This Week's Moves alongside agency plays, and describeOwnerSplit produces lines like "2 with Click Catalyst, 1 with your paid ads team, 1 with you". Before the acquisition plays were ranked in the shared list, every surface showed an all-agency board and the Monday email could never name the founder decision that was worth the most money.
The nine lifecycle plays and the three acquisition plays, by key
Lifecycle (nine): second-bottle-countdown, reorder-reminder, running-late, winback-window, subscription-moment, next-best-product, top-20-club, discount-detox, rescue-list. Defined and priced in PLAYBOOK.
Acquisition (three): budget-shift ("Budget Shift to the Best Money"), spend-pullback ("Budget Pull-back"), discount-kill ("Discount Code Verdict"). Priced from lens rows and discount-leak rows rather than from PLAYBOOK definitions, but they rank in the same list with stable keys, because a per-row key like "shift-product-42" could never be excluded as in-flight next Monday.
A play whose inputs are missing prices at null and never ranks, so a made-up figure can never reach a card. The acquisition keys are deliberately not handled by the tolerant key resolver, because priceMoveForKey cannot re-price them without the lens and leak rows, and a lifecycle board card with no live dollars is worse than no card.
Two of the intake plays state their relationship in one clause so two prices never read as one audience twice: "Separate from the Reorder Reminder already briefed: the reminder lands day 25, before run-out; this nudge catches customers already past day 38." The clause takes the reminder's actual board status, so the words and the board can never disagree.
Section five
Product versus promise
The section that keeps you honest in the room. The left column is enforced by arithmetic and by verifiers: it happens whether or not anyone remembers. The right column is Click Catalyst's word, and it happens because people do it. Never present a right-column item as a left-column guarantee.
What the product enforces
- One assembly builds everything about this week, and a verifier fails the build if a surface assembles its own.
- One move of the week, selected once, read by the hero, the email and the report.
- An in-flight play is excluded from the ranking, so a briefed decision is never re-asked.
- A declined play is excluded for 90 days, and the notice carries the return date.
- The haircut fires only on a same-family pair, and the prose branches on whether it fired.
- A gap under 15 percent of baseline prices at nothing and never ranks.
- A cohort under 25 cannot carry a headline.
- A play with missing inputs prices at null and never ranks, so no card can show an invented figure.
- When both holdout points exist, the held-back group's movement is subtracted before any verdict, and every surface reads that subtracted figure.
- Verdict bands are generated from the tolerance in force, so the sentence and the pill cannot drift apart.
- A briefed play's price is frozen at the brief, and a change announces itself in the note.
- Imports cannot be started from a browser, because no route imports the pipeline.
- Unaccounted rows other than zero make the mapping tool exit nonzero.
- Print and download build from the same certified content the page renders.
- A quiet week still sends the Monday email.
What Click Catalyst commits to as process
- The disagreement protocol. If a number on a screen does not match what the client believes, we stop, trace it to the canonical read and the window, and either fix the data or explain the definition. We do not defend a number we have not traced.
- Same-day briefs. A move approved on the Monday call gets its brief the same day, which is also what puts the play into the briefed stage and takes it out of next Monday's ranking.
- Four noes is our problem, not theirs. If a client declines four moves in a row, the fault is in our ranking or our framing. We re-look at the whole board rather than sending a fifth.
- We run the holdout the brief specifies. The 10 percent holdout is in the brief. Actually holding it back, and recording the two figures, is a human act. The product will honestly say "vs baseline" if we did not.
- The quarterly declined-play re-look. The 90-day hold expires on its own; putting the move back in front of the client with what changed is us.
- Readout discipline. Closing a readout on time, and writing the honest verdict when it is flat, is a person choosing not to spin it.
- The operator gates. Every "Benjamin says go" in the runbook is a promise, not a lock. The tools will refuse without the flag, but the flag is you.
- Data quality judgement. The app rates the data. Deciding that a Limited rating stays internal-only is us.
The sentence to have ready. "The arithmetic is enforced in the software, and the promises are enforced by us. I will show you which is which, and you should hold us to the second list harder than the first." That answer lands better than claiming everything is automatic, and it is true.
Section six
The client conversation
The platform answers three questions and only three: am I on track or off track, why, and what do I do this week, worth how much, done by whom. Run the demo in that order and the software does the work.
Where to start a demo
Open on /home and stay on the verdict card for a full thirty seconds before scrolling. Do not narrate the page. Let them read the pill and the dollar figure, then ask "does that match what you thought?" Whatever they answer, you are now in a conversation about their business rather than a software tour.
- Scoreboard verdict card. One verdict, one dollar figure. Open one why-fold, not five: the priced driver behind the worst tile.
- This week's moves. The ranked board with the owner split. Point at the split, not the board: "two of these are ours, one is your paid team's, one is yours."
- The one decision. Ask for exactly one yes. Then stop.
- Only if asked:
/retentionfor the cohort curves,/acquisitionfor payback,/reports/monthlyfor the paper version.
The one decision ritual
Every surface asks for exactly one yes, and it is the same yes on all of them: the Monday email's top block, the scoreboard hero, the moves board headline and the monthly report's single decision all name the same move, because they all read the same selectMoveOfTheWeek result. The ritual is:
- Name the move, its dollar range and its owner in one sentence.
- Ask for the yes.
- On a yes, the brief goes out the same day and the play leaves next Monday's ranking.
- On a no, use the menu, not the drag. "Not this week" is a real decision with a real 90-day consequence, and the client should see the dated notice appear.
- Never ask for a second yes in the same conversation. A second ask turns a decision into a shopping list.
Answering the hard questions
"Where does $115 to $206 per customer come from?"
Two honest answers, and you need both because they are different objects.
In the deck, $115 to $206 is one illustrative constant: what one extra repeat customer is worth in margin over the following year. The arithmetic is roughly 2 to 3.5 further orders at a $93 average order value at 64 percent margin, which runs $119 to $208 and lands within a few dollars of the figure used. The deck leans on it deliberately so every slide reconciles to one number, and the sliders land on the middle of each tile because both come off the same spread.
In the app, there is no single per-customer constant. Each play family is priced by its own model with its own assumption band, and the card names its weakest assumption as one trailing clause. That is why two moves can carry different-looking ranges: they are different models, not different opinions about one number.
| Model | Arithmetic | Band and stated assumption |
|---|---|---|
| Cohort bleed | new customers a month x 12 x rate gap x remaining LTV per repeat customer x 0.75 | 0.75 to 1.35. "assumes a converted first-time buyer goes on to spend like today's repeat buyers, less a 25 percent haircut" |
| Reminder window capture | window entrants a month x AOV x gross margin | 1.5 to 3 percent. "assumes reminder flows convert 1.5 to 3 percent of the window incrementally until a holdout proves the real rate" |
| Subscription attach | order-2 customers a month x LTV gap | 5 to 10 percent. "assumes a 5 to 10 point attach lift from the order-2 push, verified at the 60-day readout", or the 1.8x fallback until subscription data confirms the gap |
| Win-back, net | lapsed pool x reactivation rate x (margin per order minus offer cost) | 2 to 5 percent. "assumes 2 to 5 percent reactivation against a 10 percent holdout, with the blended offer cost netted out". Can go negative, and the surface must then say the offer costs more than it recovers. |
| Spend reallocation | shifted budget x value-per-dollar gap x confidence haircut, 0.5 until one 90-day readout confirms and 0.75 after | 0.6 to 1.2. "assumes CAC holds within 20 percent while the budget shifts; the 90-day cohort readout settles it" |
| LTV to CAC gap | (target ratio x blended CAC minus LTV per customer) x new customers a month | 0.8 to 1.2. "assumes today's value per customer and new-customer volume hold; the 90-day cohort readout settles it" |
| Discount kill | repeat-order redemptions a year x average discount value | 50 to 70 percent. "assumes 50 to 70 percent of the repeat-order redemptions would have reordered without the code". First-order redemptions never count: they may be buying real acquisition. |
The reconciliation line to say out loud: "the deck uses one constant so the slides tie to each other; the app prices each play with its own model and shows you the assumption. If you want to check one, open the card and read the trailing clause."
"These two screens look different."
Do not defend. Diagnose, out loud, in this order.
- Different basis? Margin after product costs versus revenue before costs. Play dollars and cohort bleed are margin; cohort realised value and the acquisition money rows are revenue. Both are labelled, and the label is the answer.
- Different window? The monthly report's column header is "This week's read" and each row names its own window. Repeat rate is 90 days, second purchase is 60 days, revenue is 30 days. Two true numbers over two windows are not a contradiction.
- Different clock? A closed month rebuilt from current data says so. A briefed play's price is frozen at the brief date and the note says which.
- A roll-up versus its parts? If two plays sit in the same overlap family, the combined figure carries the 25 percent haircut and the prose says so. The parts will add to more than the total, and that is the rule working.
- Still different? Then it is a bug, and you say so plainly: "that is a real disagreement, I will trace it and come back today." One truth is architectural here, so a genuine fork is a defect with a name, and there is a verifier whose job is to catch that class. Say that, then go fix it.
What you never say: "the dashboard calculates it differently." That sentence concedes the whole one-truth argument.
"How do you know the play caused it?"
Give the honest three-level answer, in order, and never skip a level.
- Without a holdout, we do not claim cause. The badge reads "vs baseline" and the note reads "Movement is observed alongside the play, not proven by it." That string is in the software, not in the pitch.
- With a holdout, we subtract. The brief specifies a 10 percent holdout. When it runs, the readout is a difference in differences: the held-back group's movement comes off the top before any verdict. If the market lifted everything, the play reads flat. The badge changes to "vs holdout" and the note to "Read against the holdout the brief specified."
- Every priced move is a model until its readout. Combined figures carry the "modelled, before testing" chip, and each card names its weakest assumption. The 90-day cohort readout is what settles the budget plays.
The current caveat you must state: holdout recording is pending one schema change, so today every readout badge reads "vs baseline". Say it as sequencing, not as a gap: the first holdout lands with the first real play.
"Why did last week's recommendation disappear?"
Because it was briefed, and a briefed play is excluded from the ranking so it is never re-asked. It has not vanished; it is in the lifecycle columns with its countdown and its readout date. Or because it was declined, in which case there is a dated notice saying when it comes back.
"Half these tiles say NEEDS DATA."
On a first import over a short window, that is the sample floor doing its job: nothing under 25 carries a headline. Say what fills them: more weeks of the Monday series, a wider order window on the next import, and ad spend connected for the payback panel. Until ad spend is connected, cost per customer stays null and the payback day is a proxy, never a payback claim.
The nav labels and page headings, so you never misname a page
| Nav label, in order | Route | Page heading |
|---|---|---|
| Scoreboard | /home | "Your scoreboard" |
| This Week's Moves | /moves | "This week's moves" |
| Retention | /retention | Matches the nav label |
| Acquisition & Payback | /acquisition | "Acquisition" |
| Reports | /reports/monthly | "Monthly report" |
| Methodology | /methodology | Matches the nav label |
Two things to hold in your head. The nav label and the heading differ on three of the six, so say the nav label when you are telling someone where to click and the heading when you are quoting what they will see. And Settings is not in the nav: it is reached from the "Connect ad spend in Settings" buttons and from tile links. Do not tell a client to click Settings in the sidebar.
Vocabulary that must never reach a client screen or your mouth on a call
These are the banned strings in the CEO audit's first hard gate, and they are worth avoiding out loud too, because a client who hears them starts asking about the software instead of their business: governance, migration, tenant, session, Supabase, RLS, gate, blocked, suppressed, directional, read-only, approval workflow, canonical, seeded, demo (outside the single app-wide sample-data banner), snapshot rows, write boundary, dry run, schema, enum, staff first names.
Also banned by construction: snake_case or raw enum keys visible in any rendered string, and any sentence whose grammatical subject is the app, the page, the data pipeline or the build. If a sentence explains the software, delete it.
The one-sentence test for anything you are about to say: would the operator behind a $50M consumables brand say this out loud to a client on a Monday call?
Section seven
The operating calendar
The client's week is 3.5 minutes long. Everything else is yours.
The client's week
| When | What happens | Time it takes them |
|---|---|---|
| Monday 07:00 Brisbane | The Monday email lands. Five blocks, the measure that needs you on top, one decision, owner lines using the sentence nouns. A quiet week still sends. | 90 seconds |
| Monday, whenever they open it | The scoreboard verdict card, one why-fold, the ranked board with the owner split. | 2 minutes |
| Monday, one action | One yes or one "Not this week". | A few seconds |
| Month end | The monthly report, with exactly one decision, the same one Monday asked. Block 7 folds closed with the summary "{n} tracked this month, {m} with limited evidence". | 5 minutes |
Total weekly ask: 3.5 minutes. That number is a design constraint, not a marketing line. Any change that adds a second decision or a second caveat sentence to a section is breaking it.
The agency's week and quarter
| Cadence | What runs, and what you do |
|---|---|
| Sunday 20:00 UTC / Monday 06:00 Brisbane | /api/jobs/weekly-snapshot, schedule 0 20 * * 0. Writes the week's row into the weekly series and closes out any live play whose readout clock has run out. |
| Sunday 21:00 UTC / Monday 07:00 Brisbane | /api/jobs/weekly-digest, schedule 0 21 * * 0. Builds the digest from the shared assembly and sends. The one-hour gap is deliberate: the email must read the snapshot the earlier job just wrote. |
| Monday, after the send | Read the same email the client got. If a brief is approved, it goes out the same day, which is also what removes the play from next Monday's ranking. |
| Every 90 days per play | The readout cadence. A live play's clock runs out, the readout summary is written, and the verdict is judged on the holdout-subtracted figure when one exists. |
| Quarterly | The declined-play re-look. The 90-day hold expires on its own; putting the move back in front of the client with what changed since is your job. |
| Monthly, before the report goes out | Check the report reads on one clock, that every finding is priced, that there is exactly one decision, and that closed months predating snapshot history say they were rebuilt from current data. Print and download build from the same certified content, so reviewing the page reviews the export. |
| Before any first import | The three go gates on the call sheet, in order, said out loud. |
The standing engineering gates
Any future copy change, however small, clears all three of these before it ships. This is the part that is easy to forget when a client asks for one word changed on a call.
ceo-audit
The scathing eight-figure consumables CEO persona. Five passes in order (glance, action, language and trust, persona fit, ritual), then ten hard gates where any single failure is a fail, then a score. Ships at 8 or above with all gates passing.
.claude/skills/ceo-audit/SKILL.md
engagement-audit
The interaction, gamification and CRO persona at Linear and Stripe grade. Four lenses (fun, CRO mechanics, interaction craft, motion), then hard gates covering loading, empty and error states, reduced motion, layout shift, 44px targets, drag fallbacks and the full keyboard path.
.claude/skills/engagement-audit/SKILL.md
143 verifiers
npm run verify. Many of them pin copy strings and source-level structure rather than arithmetic, which is exactly why a wording change is an engineering change. verify-digest-parity is the one that keeps the surfaces agreeing.
scripts/verify-*.mjs via scripts/run-verifiers.mjs
The commitment this implies. When a client asks for a wording change on a Monday call, the honest answer is not "sure, done this afternoon". It is: "yes, and it goes through the same two audits everything else did, so it lands correct rather than fast." Nothing merges with an open hard-gate failure, and a rule sentence lives in one generator, so changing it changes it everywhere at once. That is the feature. Say it that way.
Section eight
Where everything lives
Repository paths, so nothing has to be searched for mid-call. All paths are relative to /Users/benjamin/Projects/clv-opportunity-studio.
Runbooks and the first run
| What | Path | State |
|---|---|---|
| The binding import runbook | docs/shopify-import/first-real-client-import-dry-run.md | Binding. Where it and the call sheet disagree, the runbook wins. |
| The operator cockpit, the three CLIs | docs/shopify-import/operator-cockpit.md | Current |
| The Wisdom Foods call sheet | docs/shopify-import/wisdom-foods-first-run-call-sheet.md | Ready to run top to bottom. Section A fully open. |
| The approval record | docs/client-builds/wisdom-foods-first-import-dry-run.md | Draft, unsigned. Awaits your approval date, approval source and date range. |
| The record template | docs/client-builds/_first-real-client-import-dry-run-record-template.md | Reference for the next client |
| Stage-by-stage engine docs | docs/shopify-import/raw-to-canonical-runner.md, docs/shopify-import/derivation-job.md, docs/shopify-import/import-batch-runner.md | Current |
| Live data and token handling | docs/live-data-runbook.md, docs/security/shopify-token-storage-and-rotation.md | Current |
The three operator tools
| Stage | Path | Its own approval flag |
|---|---|---|
| One, raw import | scripts/run-shopify-import.mjs | --apply --live-run-approved |
| Two, canonical mapping | scripts/run-canonical-mapping.mjs | --apply --mapping-approved |
| Three, derivation | scripts/run-derivation.mjs | --apply --derivation-approved |
| Shared CLI plumbing | scripts/lib/operator-cli.mjs (arguments, environment, credential reference, redaction, printing; opens no socket) and scripts/lib/operator-rest.mjs (read client and shared preflight) | n/a |
The code that holds the rules
| Rule or mechanism | Path |
|---|---|
| The one weekly assembly | apps/web/src/lib/email/weekly-digest-context.ts |
| Move selection, ranking, band sentence, owner vocabulary, the nine plays | apps/web/src/lib/metrics/playbook.ts |
| The seven dollar models and the overlap haircut rule | apps/web/src/lib/metrics/dollar-models.ts |
| Canonical metrics, the bands, the sample floor, the cycle grace | apps/web/src/lib/tenant/canonical-metrics.ts |
| Lifecycle, holdout notes, decline hold, in-flight keys | apps/web/src/lib/tenant/play-lifecycle.ts |
| The price-freeze rule on the board | apps/web/src/app/moves/page.tsx and apps/web/src/components/islands/PlayBoard.tsx |
| The reorder window strip and its four zones | apps/web/src/components/viz/ReorderWindowStrip.tsx |
| Monthly report content, one clock | apps/web/src/lib/tenant/monthly-report-content.ts, apps/web/src/lib/tenant/monthly-client-report.ts |
| Shared run vocabulary and Data Health | apps/web/src/lib/tenant/import-runs.ts, apps/web/src/lib/tenant/data-health.ts |
| The cron schedules | vercel.json |
Awaiting your hand
| What | Path | What it unblocks |
|---|---|---|
| The pending migration | supabase/migrations/202607260001_play_holdout_and_decline.sql | Adds holdout_pct and widens the lifecycle status constraint to accept declined. Until applied, holdouts cannot be recorded and every badge honestly reads "vs baseline"; declines route through the brief jsonb marker. |
| The Vercel production variables | SHOPIFY_TOKEN_SECRET_REF, the token variable it names, SHOPIFY_API_VERSION, SHOPIFY_CONNECTION_FLOW_ENABLED | The whole first import. Needs a redeploy after setting. |
| The signed approval record | docs/client-builds/wisdom-foods-first-import-dry-run.md | Stage one. The runbook forbids proceeding on an implied approval. |
Skills and audits
| Skill | Path | When it runs |
|---|---|---|
| ceo-audit | .claude/skills/ceo-audit/SKILL.md | Every client-facing page, module, component, copy string or email, before it ships |
| engagement-audit | .claude/skills/engagement-audit/SKILL.md | Every interactive element, visualisation or flow built |
| orchestrator | .claude/skills/orchestrator/SKILL.md | Any multi-component build, fix sweep, audit cycle or migration. Holds the operating model: the lead decomposes and dispatches, builders build, scouts scout, and the seams get audited rather than only the pages. |
Background context the two audit personas point at: docs/ceo-critique-2026-07-17.md, docs/master-plan/03-retention-operating-system.md (section 6 is the voice guide, section 1 the play library, section 3 the good bands, section 4 the dollar models) and docs/master-plan/02-interaction-catalogue.md (the motion system and copy law). The build history, including the incidents each rule came from, is in docs/build-log.md and the numbered docs/ceo-critique-*.md passes.
The two documents in your hands
| Document | Where | Audience |
|---|---|---|
| The client walkthrough | Published as its own artifact; you already hold the link. The source file is clv-walkthrough.html in this session's scratchpad. | Client-facing. Every string in it has cleared the CEO voice gates. |
| This document | clv-mastery.html in the same scratchpad | You alone. Internal vocabulary throughout. Never shown to a client. |
| The certified rule summary this page quotes | final-app-state.md in the same scratchpad | Internal. The binding wordings for any client-facing document. |
No links out of this page. Every path above is a path in the repository or in this session's scratchpad, and the walkthrough's published URL is the one you already have rather than one guessed at here.