Skip to content

docs(marketing): campaign enrollment is a button, not a trigger or a cron - #1860

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-1854-enrollment-runs-itself-docs
Sep 10, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-1854-enrollment-runs-itself-docs

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #1854

Both marketing pages said the Campaign Enrollment flow runs itself, and they said it in two different and equally false ways.

page line on 7c09fb8 what it claimed
marketing/campaign-members.mdx (+ 2 siblings) :14 "when a campaign moves to In Progress, the Campaign Enrollment flow runs and adds everyone matching the audience criteria"
marketing/campaigns.mdx (+ 2 siblings) :62 under What happens automatically — "runs every Monday at 9 AM and adds new matching audience members"
marketing/campaigns.mdx (+ 2 siblings) :67, :69, :71, :74 "the system bulk-enrolls your target audience" · "runs on a schedule (default Monday 9 AM)" · "Reads the campaign's target audience criteria" · "Optionally triggers the campaign's send action"
marketing/campaigns.mdx (+ 2 siblings) :130 admin tip — "the enrollment cron (Monday 9 AM) is configurable"

The card named :69 and :71. Re-reading found four more sites of the same claim on the same page, including one the card did not reach at all: the admin tip at :130 offers to configure a cron that does not exist, and sends the reader to administration/automation — a page which has always listed this flow correctly as Screen (automation.mdx:60). So the app's own admin reference and its marketing page disagreed, and the marketing page was the wrong one.

What each new claim is sourced from

claim source
it is a screen flow with no trigger of its own src/flows/campaign-enrollment.flow.ts:52 → type: 'screen'. No start node in that file carries a schedule key; the only schedule: cron on a campaign is campaign-completion.flow.ts:36 (0 2 * * *), which is the auto-complete sweep the page keeps
the only entry point is Enroll Members EnrollLeadsAction, src/actions/campaign.actions.ts:22-35 — name: 'enroll_leads', label: 'Enroll Members', objectName: 'crm_campaign', type: 'flow', target: 'campaign_enrollment'
in the record header, while Planning or In Progress same action — locations: ['record_header', 'record_more'] and visible: record.status == "planning" or record.status == "in_progress"
the audience is answered on a screen, not read off the campaign the screen_1 node, labelled Enrollment Criteria (:116), carries memberSource (Leads / Contacts), leadStatus and contactDepartment, all three required: true (:118-159). crm_campaign stores no audience criteria — its only Target Size field is a plain number in the metrics group (campaign.object.ts:189)
one run enrolls one side, not both edges e4 and e7 branch on vars.memberSource != "contacts" / == "contacts" (:336-351), so a run reaches query_leads or query_contacts, never both. The old wording — "finds matching leads and contacts" — described a shape the graph does not have
both segment fields carry a default leadStatus defaultValue: 'new', contactDepartment defaultValue: 'executive' (:134, :147)
eligibility: has an email, not opted out, leads not already converted query_leads filter status / is_converted: false / email $ne null / email_opt_out: false (:176-188); query_contacts the same minus is_converted, "a contact IS the converted end state" (:246-258)
a member per matching person at Sent, already-enrolled skipped the dedupe get_record plus the existingMember == null edge (:204-240), and the insert subflow's status: 'sent' (campaign-member-enroll.flow.ts:67-71)
there is no send action to trigger grep -rn "objectName: 'crm_campaign'" src/actions/ returns exactly one action, enroll_leads. Nothing on the campaign sends mail, so :74's "optionally triggers the campaign's send action" was a fourth phantom in that list
why it is manual — the clause the card asked me to carry the flow's own docblock, :13-19: it "was previously a schedule flow (Monday 9 AM cron) with campaignId / leadStatus input variables — but a cron firing seeds NO inputs … so every run had both variables undefined: either it matched no campaign, or it mass-enrolled every open lead into a null campaign and died on the 'Campaign required' validation"

That last row is the one that changes the tone of the fix. The pages now say there is no schedule behind this and give the reason: the criteria are answered on the screen, and a scheduled run would arrive with nobody to answer them. It is a design fact, not a missing feature. Nothing in the diff describes the removed cron as returnable, and nothing proposes adding one.

The Chinese half is sourced, not translated

zh-Hans names the action as the zh-CN pack does — 批量加入成员, the shipped label for enroll_leads (src/translations/zh-CN/objects.marketing.ts:90-93) — glossed after the English label, the pattern these pages already use for Add to Campaign(加入营销活动), Related(相关)and New(新建).

zh-Hant mirrors it in each page's own Traditional conventions: 批次加入成員. 批次 is the spelling the Traditional docs already use for this idea (campaign-members.zh-Hant.mdx:15 "批次執行", administration/automation.zh-Hant.mdx:60 "批次把符合條件的線索或聯絡人登記進本行銷活動"), never a mechanical conversion of 批量. Same for 記錄頁標頭 (sales/meetings-and-calls.zh-Hant.mdx:31) against zh-Hans 记录页头部, and 畫面 against 屏幕 — each page's own word, taken from that page's own family.

The two Chinese pages carry no anchored link into #campaign-enrollment-flow today, because their headings are Chinese and the English slug would not resolve there — exactly the failure mode docs-anchor-links was written for. The replacement prose keeps them link-free and names the section in text, so no locale gains or loses an anchor.

Docs guards that read these pages — measured, not reasoned

The read-set was measured with the instrument PR #1855 introduced: the whole suite re-run under a scratchpad-only vitest config that aliases node:fs and node:fs/promises to a facade recording every read whose path is one of the six pages, attributed to the test file on the call stack. Nothing in the repo was touched — the alias breaks 7 unrelated fs-tooling suites (7 failed / 158 passed under the probe, against 165/165 without it), which is why it is a probe and never a commit.

15 guards read a page body. Twelve read all six files; three read only the pages they are scoped to.

guard why it stays green
docs-anchor-links (via test/helpers/docs-anchors.ts) every anchored link must resolve to a real heading. No heading is renamed — ## Campaign enrollment flow is kept verbatim precisely because two links target its slug — and no locale gains or loses an anchored link (English 2/2 before and after on both pages, Chinese 0/0). Proved live: see the ablation below
docs-locale-callouts callout parity across locales. Blockquote runs are 1 / 1 / 1 on each page before and after; the diff adds and removes no > block. Proved live: see the ablation below
docs-object-coverage wants each page to exist in three locales, sit in each meta*.json and name its object — all unchanged, and both pages still name crm_campaign / campaign members
docs-view-rosters keys on content/docs/marketing/campaigns.mdx for crm_campaign's four saved views. The Standard list views section is not touched by any hunk
docs-metadata-counts every count a page states must equal what the stack registers. Its CLAIMS patterns match N business objects, N flows, N dashboards, N datasets, **N actions**, N positions and their zh spellings — the new prose states none of them, and the counts already on the pages ("The 4 campaign statuses", "The 8 campaign types", "four views") are outside every hunk
docs-setup-navigation-names parses the first segment of every bold **App → child** path. Bold-arrow runs on these six pages: 0 before, 0 after. The new prose deliberately uses no arrow — the one arrow that was on the page, (status → *In Progress*), is removed with the sentence that carried it
docs-drift two families: flow-vs-source prose in src/docs/*.md, and the large-deal CEL threshold across content/docs. This diff touches neither src/docs/ nor any threshold; no page here states the deal condition
docs-object-term-consistency pins crm_case / crm_quote terms across the Chinese pages. Neither object is named, and no retired spelling is introduced
docs-conversion-rate-spelling sweeps the Chinese pages for 转换率 / 轉換率. The new Chinese text contains neither (it says 转化 / 轉化 where it mentions conversion at all)
docs-zh-hant-justification the zh-Hant convention note must sit on exactly the declared pages and state its sanctioned reason. Neither page carries one, none is added, and no retired reason is asserted
docs-retired-personas no retired copilot persona is named
docs-role-hierarchy no role name is named
docs-runnable-samples no code fence added or removed (campaigns 0/0, campaign-members 2/2)
docs-analytics-vocabulary no dataset / cube / report vocabulary added
docs-dashboard-tiles no dashboard tile name added

Two more touch the files without reading a body: docs-app-workflow-paths (path globs only) and labeler-config (a directory listing under content/docs/marketing). scripts/check-source-token-ratchet.mjs measures src/**/*.ts only, so a docs edit is outside its surface — the readings are byte-identical before and after (business semantics ~85,285 · interaction layer ~38,335 · authored total ~138,265).

Parity ablation — measured, and it did not fire

The obligation is conditional: it is owed if the diff adds or removes something a guard counts. So the counted quantities were measured on all six pages, working tree against HEAD: blobs, rather than argued:

                              callouts  anchored-links  headings  bold-arrow  table-rows  code-fences
campaigns.mdx                    1            2            14         0           30          0
campaigns.zh-Hans.mdx            1            0            14         0           30          0
campaigns.zh-Hant.mdx            1            0            14         0           30          0
campaign-members.mdx             1            2            11         0           16          2
campaign-members.zh-Hans.mdx     1            0            11         0           16          2
campaign-members.zh-Hant.mdx     1            0            11         0           16          2

Identical in both columns of the comparison — every figure above is the before row and the after row. Nothing a guard counts moved, so the condition is not met.

That says nothing about whether those guards could have caught a change, so both counting families were ablated on the committed tree. Each leg mutates, proves the mutation reached disk by re-counting the exact token, runs the guard with its exit code captured before any pipe, then restores with git checkout HEAD -- (absolute path) and proves the restore by blob hash against HEAD:

leg mutation (on-disk proof) guard exit restored
A delete the one blockquote from campaigns.zh-Hans.mdx — ^> lines 1 → 0 docs-locale-callouts 1 (red) — "campaigns.zh-Hans.mdx: 0 callout(s), but campaigns.mdx has 1" blob back to 5333e61 = HEAD: blob; git diff HEAD empty
B repoint the anchored link on campaign-members.mdx:14 at #campaign-enrollment-cron — 1 hit for the new slug docs-anchor-links 1 (red) — "campaign-members.mdx:14 … has no heading with id "campaign-enrollment-cron"" blob back to 14f1a80 = HEAD: blob; git diff HEAD empty

Leg B is the useful one: the line it names, campaign-members.mdx:14, is the line this PR rewrote, so the anchor in the new sentence is demonstrably resolved by a live guard rather than merely present.

Verification

pnpm verify run stage by stage on the committed tree at 9add45d, every exit code captured before any pipe (pnpm stage file 2 to 1; EXIT=$?). The four heavy stages ran through this container's shared verify lock.

stage exit evidence
pnpm validate 0 pre-existing advisories only (carrier-only field warnings, the enterprise-capability note, the approval-slate notes)
pnpm typecheck 0 tsc --noEmit, no output
pnpm lint 0 4 warning(s), 12 suggestion(s) — all pre-existing
pnpm lint:i18n-gate 0 ✓ i18n lint gate: 0 i18n/missing-* issues
pnpm hygiene 0 ✓ source hygiene clean
pnpm hygiene:tokens 0 ✓ source token ratchet clean — readings unchanged
pnpm build 0 Artifact: dist/objectstack.json (1916.9 KB)
pnpm test 0 165 files, 3455 passed / 1 skipped (3456)

Test-file and test counts before the change, measured on this same worktree at 7c09fb8: 165 files, 3455 passed / 1 skipped (3456) — identical. No test file is added or removed, and nothing under test/** is touched (#1579's fence).

Also self-scanned for control bytes beyond check-source-hygiene's pass: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the six pages returns nothing.

Scope

Six docs files plus the changeset. ⛔ No metadata change, ⛔ nothing under test/**, ⛔ no content/docs/releases/ edit. The changeset is written, not skipped: this ships to users as product documentation.

Two sentences were read for the same false-automation shape and deliberately kept, so the judgement is on the record rather than silent: campaigns.mdx:15 ("In Progress | Live — members are enrolled, and the responses that come back are recorded on them") describes what characterises a live campaign rather than claiming a trigger, and campaign-members.mdx:66 ("the enrollment flow skips opted-out people") is true — query_leads / query_contacts both filter email_opt_out: false, and campaign_member_optout_sync is what populates that column.

Out of scope — filed, not fixed here

  • marketing/index still promises "Automatic enrollment" — the fifth false-automation claim about the same screen flow #1859 — content/docs/marketing/index.mdx (+ both siblings) carries the same claim twice more: :34 is a bullet titled Automatic enrollment under the heading What the system does for you, and :27 has "the system bulk-enrolls the target audience". The page's ASCII life-cycle diagram draws it as an arrow out of In Progress too, so the fix is a page-structure decision and not a line edit — which is why it is filed rather than folded into this diff. It survived the two earlier passes over this page (the opens/clicks sweeps) because both were rewriting the bullet directly below it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj


Generated by Claude Code

…cron

Both marketing pages said the Campaign Enrollment flow runs itself, in two
different and equally false ways: `campaign-members` item 1 had it firing "when
a campaign moves to *In Progress*", and `campaigns` had it "on a schedule
(default Monday 9 AM)" in three places — the *What happens automatically* list,
the *Campaign enrollment flow* section, and an admin tip offering to configure
the cron.

`campaign_enrollment` is `type: 'screen'` (`campaign-enrollment.flow.ts:52`)
with no trigger of its own. Nothing fires it on a status change, no start node
carries a schedule, and `grep -rn "campaign_enrollment" src/` finds one entry
point: `enroll_leads`, label **Enroll Members**, `type: 'flow'` on
`crm_campaign`, `locations: ['record_header', 'record_more']`, visible while the
campaign is *Planning* or *In Progress*. A reader who launched a campaign and
waited was waiting for members that never arrive, and neither page named the
button that produces them.

All three locales of both pages now describe the real gesture — open the
campaign, click **Enroll Members**, answer the *Enrollment Criteria* screen
(which side, one per run; then leads by status or contacts by department) — and
keep the true half: a member per matching person at *Sent*, already-enrolled and
opted-out skipped. The design reason travels with it, because it is what keeps
the cron from coming back: the criteria are answered on the screen, and a
scheduled run has nobody to answer them.

The admin tip keeps the auto-complete cron, which is real (2 AM daily,
`campaign_completion`), and drops the enrollment cron — `administration/automation`,
the page it links to, has always listed this flow as *Screen*.

zh-Hans takes the zh-CN pack's shipped label for `enroll_leads` (批量加入成员);
zh-Hant mirrors it in each page's own Traditional conventions (批次加入成員).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuzfS5chho38Yx1jxx9DEj
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 10, 2026 6:24am UTC

Request Review

@os-steve
os-steve added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 2231583 Sep 10, 2026
10 checks passed

Copy link
Copy Markdown
Collaborator

PM review → accepted, auto-merge armed.

⭐ The card named two sites. You found six.

:69 and :71 were what the card and my dispatch listed. Reading the whole page produced four more of the identical false claim — the "What happens automatically" bullet at :62, the section opener at :67, a phantom send action step at :74, and, the one nothing had reached, the admin tip at :130 offering to configure an enrollment cron.

⭐⭐ That last one is the worst site on the page and it is worse than the class we have been chasing. The other five describe automation that does not exist; :130 instructs an administrator to go set it up — and points them at administration/automation, a page that has always listed this flow correctly as Screen. So the docs contradicted each other, and the half a reader would act on was the wrong half.

The line-number discipline is exactly right and worth stating: :69/:71 were still accurate on 7c09fb8 — the card was not stale this time — but :62 and :130 would have been missed by anyone who edited only the named lines. ⭐ Re-reading is not insurance against a stale card; it is how you find the sites nobody wrote down.

Carrying the docblock's reasoning clause — a scheduled run has nobody to answer the criteria the screen collects — turns "this is manual" from a limitation into a design fact, which is what the dispatch asked for and the harder half to write.

The verification set a new bar twice over

The read-set was measured, not reasoned — PR #1855's instrument reused correctly: 15 guards read a page body, two more touch without reading one, the 7 unrelated fs-tooling suites broke exactly as #1855 predicted, and the probe was committed nowhere.

⭐⭐ And the parity ablation was handled the honest way round. You first measured that the trigger condition is not met — every guard-counted quantity identical before and after (callouts 1/1/1, anchored links EN 2 / zh 0, headings 14 and 11, nav arrows 0, table rows 30 and 16, code fences 0 and 2) — and then ablated both counting families anyway, to prove they are live rather than vacuous.

That ordering is the point. "I did not trip the guard" and "the guard could not have caught me" look identical in a green run; only the ablation separates them. ⭐ And leg B is load-bearing: repointing the anchored link on campaign-members.mdx:14 — the exact line this PR rewrote — reds docs-anchor-links naming that line and the missing heading id. That is the strongest form of this leg anyone has run in this series.

Both restores proved by git hash-object against the HEAD blob with git diff HEAD empty, never by a restore command's exit code.

The Chinese half

⭐ You looked up the shipped label rather than translating mine: enroll_leads is 批量加入成员 in the zh-CN pack (objects.marketing.ts:90-93), glossed after the English in the pattern the page already uses; zh-Hant mirrors with 批次加入成員, the 批次 spelling those pages already carry. My dispatch said "look up Enroll Members' own pack label rather than coining one" — that is what looking it up looks like, and the answer was not the literal translation.

#1859 — filed correctly, and its diagnosis is the useful part

The same false claim twice more on marketing/index: :34 is a bullet literally titled "Automatic enrollment" under the heading "What the system does for you", and :27 says the system bulk-enrolls the audience. The ASCII life-cycle diagram draws it as an arrow out of In Progress, so it is a page-structure decision, not a line edit — right call to file rather than fold in.

⭐ And the observation underneath it is the one I want kept: it survived two earlier passes over this very page because both were rewriting the bullet directly below it. A sweep's blind spot is the line adjacent to the one it came for. That is now standing guidance for this series, and #1859 is claimed and dispatched with it.

Auto-merge armed (squash). #1854 closes on merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant