You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
campaign-members calls the lead-side Add to Campaign "the only picker-driven path that ships" — #597 shipped the contact-side mirror too #1845
Found while fixing #1829, which rewrote the status / field / metrics sections of the same page. ⛔ Out of that card's scope — the sentence below is #961's own wording in a section #1829 did not name, so it was filed rather than quietly rewritten.
The finding
content/docs/marketing/campaign-members.mdx:15 (and both Chinese siblings, same line) lists the second of three enrollment paths:
From a lead — open a lead (or tick several in a lead list view) and run Add to Campaign, then choose the campaign. … This is the only picker-driven path that ships: there is no Add Members button on the campaign that picks people from lead and contact list views.
Two measured problems, both against origin/main at 62e23f5:
1. There are two picker-driven paths, not one.add_contact_to_campaign (src/actions/contact.actions.ts:70) is an exact mirror of create_campaign on the lead — same label (Add to Campaign), same locations: ['list_toolbar', 'list_item'], the same field-backed crm_campaign param that resolves a record picker, and it is wired the same way (bulkActions: ['add_contact_to_campaign'] in src/views/contact.view.ts:36, mirroring bulkActions: ['create_campaign'] in src/views/lead.view.ts:204). Its own header says why it exists: before it, "crm_campaign_member.crm_contact was a lookup no writer populated: campaigns could only ever reach LEADS".
The trailing clause is still true — no Add Members button exists on the campaign — but the sentence in front of it is not.
2. Neither action is reachable by opening a record. Both are list_toolbar + list_item only; neither appears in a record page's header actions (nothing under src/pages/ names either action). So "open a lead … and run Add to Campaign" describes a gesture the console does not offer: the reader has to be in the lead or contact list.
content/docs/marketing/campaigns.mdx:74 already documents the truthful version — "the Add to Campaign action on any lead/contact" — so the two marketing pages disagree with each other today.
Where it came from
The contact-side mirror was added by #597, the same card whose metadata trim left the phantom statuses #1829 fixed. #961 corrected this page's list-view paragraph afterwards and wrote the "only picker-driven path" sentence about the lead path; the contact path was already shipping, so the claim was never true rather than having gone stale.
What a fix looks like
Rewrite item 2 of "How members get enrolled" (three locales) so it names both entry points and the surface each is on — a lead list or a contact list, from the selection toolbar or a row — and keep the true half about there being no Add Members picker on the campaign. The dedupe behaviour differs slightly between the two and is worth one clause: the contact action scopes its skip-check on crm_contact, so a person enrolled once as a lead and once as a contact is deliberately not treated as a duplicate.
Refs: #597 (shipped the contact action) · #961 (wrote the sentence) · #1829 (rewrote the rest of this page).
Dispatched by the hotcrm PM seat (#1353). The dev inherits this claim — ⛔ no second Claim:, ⛔ do not touch the assignee.
Base off main fetched fresh — PR #1846 has landed; it rewrote this page's status / field / metrics sections and deliberately did not touch "How members get enrolled". Your sentence is still there at :15. Model: Opus. Deliverable: one PR, ready for review, pnpm verify green. ⛔ Do not arm auto-merge, ⛔ do not merge.
PM verification — both measured claims hold, checked independently
There are two picker-driven paths.add_contact_to_campaign (src/actions/contact.actions.ts:70) carries label Add to Campaign, and is wired bulkActions: ['add_contact_to_campaign'] at src/views/contact.view.ts:36 — mirroring bulkActions: ['create_campaign'] at src/views/lead.view.ts:204, with the comment at contact.view.ts:32 naming Campaign member lifecycle: support contacts, live metrics, and trim tracker fields that have no writer #597 and the mirror explicitly. ⇒ "the only picker-driven path that ships" was never true, not merely stale.
Neither action is reachable by opening a record.grep -rn "add_contact_to_campaign\|create_campaign" src/pages/ returns nothing — an absence with its command, since that is the claim. Both are list_toolbar + list_item. ⇒ "open a lead … and run Add to Campaign" describes a gesture the console does not offer.
⭐ Worth knowing before you write: the action's own header (contact.actions.ts:50-62) explains the bare-string bulkActions form fans out once per selected row with that row's recordId and no selection array, and that the campaign param is field-backed so the console resolves a real record picker rather than a paste-the-ID box. That is what "picker-driven" means here — use it, do not re-derive it.
What to fix
Rewrite item 2 of "How members get enrolled" in all three locales so it names both entry points and the surface each lives on — a lead list or a contact list, from the selection toolbar or a single row — and keep the true half: there is still no Add Members picker on the campaign.
Say plainly that the entry point is a list, not an open record. ⚠️ Do not preserve "open a lead (or tick several…)" — the first half of that gesture does not exist.
One clause on the dedupe difference is worth it, as the card says: the contact action scopes its skip-check on crm_contact, so a person enrolled once as a lead and once as a contact is deliberately not a duplicate. Verify that in the action body before writing it.
content/docs/marketing/campaigns.mdx:74 already states the truthful version ("the Add to Campaign action on any lead/contact"). ⭐ The two marketing pages disagree today — after your change they must agree. Read that line and match it rather than inventing a third phrasing.
Scope
Three files: content/docs/marketing/campaign-members.{mdx,zh-Hans.mdx,zh-Hant.mdx}. ⛔ No metadata change. ⛔ Nothing in test/**. ⛔ No content/docs/releases/ edit. ⛔ Do not re-open the sections PR #1846 just rewrote.
zh-Hans takes the zh-CN pack's wording; zh-Hant mirrors in that page's own Traditional conventions — ⛔ never a mechanical conversion (ruled on PR #1846).
⚠️ Siblings live on other surfaces: content/docs/analytics/** (#1847) and src/dashboards/executive.dashboard.ts (#1849). Neither meets your files.
Proof obligation
Name every docs guard that reads this page and why each stays green — PR #1846 and PR #1850 both did this and it is the standard now; docs-anchor-links and docs-locale-callouts (parity currently 1/1/1 after #1846) are the two most likely to move. pnpm verify stage by stage, exit codes captured before any pipe. Test-file / test counts before and after.
{
"issue": 1845,
"status": "done",
"branch": "claude/issue-1845-enrollment-paths-docs",
"pr": "https://github.com/objectstack-ai/hotcrm/pull/1855",
"premise_still_valid": true,
"summary": "Rewrote item 2 of 'How members get enrolled' on content/docs/marketing/campaign-members.{mdx,zh-Hans.mdx,zh-Hant.mdx} — one line per locale, nothing else in those files touched. It now names both entry points (Add to Campaign ships on a lead list and on a contact list), the surface each lives on (tick rows and run it from the selection toolbar, or run it on a single row from that row's row menu, choosing the campaign from a record picker), says plainly that opening a lead or a contact does not offer it, keeps the true half (there is still no Add Members picker on the campaign that reaches lead and contact list views), and carries the dedupe clause. Both PM claims re-verified independently before writing: two picker-driven paths (create_campaign at lead.actions.ts:141 and add_contact_to_campaign at contact.actions.ts:70, same label, same locations, same field-backed crm_campaign param, wired at lead.view.ts:204 / contact.view.ts:36), and no record-page reach (grep over src/pages/ returns nothing, exit 1). The dedupe difference was verified in the bodies, not taken on trust: the lead action reads fields ['crm_lead'] and builds its skip set from r.crm_lead, the contact action reads fields ['crm_contact'] and skips on r.crm_contact, so one person enrolled as a lead and again as a contact is deliberately two memberships. campaigns.mdx already carried the truthful phrasing and the new text matches it — that line is at :76 today, not :74 as the card cites (#1846 moved it two lines down). zh-Hans names the action as the zh-CN pack does (加入营销活动 — the one label both actions ship, objects.pipeline.ts:192 and objects.customer.ts:198), glossed after the English label in the pattern the page already uses for Related(相关)and New(新建); zh-Hant mirrors it in that page's own Traditional conventions (加入行銷活動, as sales/leads.zh-Hant.mdx:149 writes it), never a mechanical conversion. Changeset written, not skipped — product documentation ships to users. PR is ready for review, auto-merge not armed, nothing merged.",
"tests": "pnpm verify stage by stage on the committed tree, every exit code captured before any pipe (cmd redirected to a file, EXIT=$? on the next statement): validate 0 · typecheck 0 · lint 0 (4 warnings / 12 suggestions, all pre-existing) · lint:i18n-gate 0 ('i18n lint gate: 0 i18n/missing-* issues') · hygiene 0 ('source hygiene clean') · hygiene:tokens 0 ('source token ratchet clean'; readings byte-identical to baseline — business semantics ~85,285, interaction layer ~38,335, authored total ~138,265, since that gate measures src/**/*.ts only) · build 0 (dist/objectstack.json 1916.9 KB) · test 0. Test counts: BEFORE 165 files, 3455 passed | 1 skipped (3456); AFTER 165 files, 3455 passed | 1 skipped (3456) — identical, and no test file added or removed (#1579's fence untouched). Guard surface was MEASURED, not reasoned: the full suite was re-run under a scratchpad-only vitest config aliasing node:fs to a facade that records every read of campaign-members*.mdx, attributed per test file (nothing in the repo touched; the alias breaks 7 unrelated fs-tooling suites, which is why it is a probe and not a commit). 15 guards read the page — docs-anchor-links, docs-locale-callouts (parity stays 1/1/1; no callout added or removed), docs-object-coverage, docs-object-term-consistency, docs-conversion-rate-spelling, docs-zh-hant-justification, docs-setup-navigation-names, docs-view-rosters, docs-metadata-counts, docs-drift, docs-retired-personas, docs-role-hierarchy, docs-runnable-samples, docs-analytics-vocabulary, docs-dashboard-tiles — with a per-guard reason for staying green in the PR body. Two more touch it without reading its body (docs-app-workflow-paths, path globs only; check-source-hygiene.mjs, control-byte scan over content/, green). Control-byte self-scan over the three pages and the changeset: grep -naP over the C0 range returns nothing (exit 1). No ablation leg: a prose-only docs change adds no assertion that could be mutated. CI on PR #1855 was still running at report time (mergeable_state unstable); main advanced one commit (#1852) after my branch point and git merge-tree reports 0 conflicts.",
"mcp_calls": "9 — issue get, issue comments, PR #1846 read, dedupe search (empty) plus its control search (hit #1845/#597/#961, so the empty reading is real), issue create #1854, PR create, PR body read-back, this comment",
"open_questions": [],
"out_of_scope_findings": [
"filed as #1854: item 1 of the same list (campaign-members.mdx:14, three locales) and campaigns.mdx:69/:71 (three locales) say Campaign Enrollment runs itself — on the In Progress flip and on a Monday 9 AM cron. It is a type: 'screen' flow whose only entry point is the campaign's Enroll Members action (enroll_leads, record_header + record_more), and its own docblock records that the cron was removed because a cron firing seeds no inputs. Six files; deliberately not fixed here because this card's scope is item 2."
]
}
Activity
os-steve commentedon Sep 10, 2026
Claim:session_01DuzfS5chho38Yx1jxx9DEj→ branchclaude/issue-1845-enrollment-paths-docsDispatched by the hotcrm PM seat (#1353). The dev inherits this claim — ⛔ no second
Claim:, ⛔ do not touch the assignee.Base off
mainfetched fresh — PR #1846 has landed; it rewrote this page's status / field / metrics sections and deliberately did not touch "How members get enrolled". Your sentence is still there at:15.Model: Opus. Deliverable: one PR, ready for review,
pnpm verifygreen. ⛔ Do not arm auto-merge, ⛔ do not merge.PM verification — both measured claims hold, checked independently
add_contact_to_campaign(src/actions/contact.actions.ts:70) carries label Add to Campaign, and is wiredbulkActions: ['add_contact_to_campaign']atsrc/views/contact.view.ts:36— mirroringbulkActions: ['create_campaign']atsrc/views/lead.view.ts:204, with the comment atcontact.view.ts:32naming Campaign member lifecycle: support contacts, live metrics, and trim tracker fields that have no writer #597 and the mirror explicitly. ⇒ "the only picker-driven path that ships" was never true, not merely stale.grep -rn "add_contact_to_campaign\|create_campaign" src/pages/returns nothing — an absence with its command, since that is the claim. Both arelist_toolbar+list_item. ⇒ "open a lead … and run Add to Campaign" describes a gesture the console does not offer.⭐ Worth knowing before you write: the action's own header (
contact.actions.ts:50-62) explains the bare-stringbulkActionsform fans out once per selected row with that row'srecordIdand no selection array, and that the campaign param is field-backed so the console resolves a real record picker rather than a paste-the-ID box. That is what "picker-driven" means here — use it, do not re-derive it.What to fix
Rewrite item 2 of "How members get enrolled" in all three locales so it names both entry points and the surface each lives on — a lead list or a contact list, from the selection toolbar or a single row — and keep the true half: there is still no Add Members picker on the campaign.
crm_contact, so a person enrolled once as a lead and once as a contact is deliberately not a duplicate. Verify that in the action body before writing it.content/docs/marketing/campaigns.mdx:74already states the truthful version ("the Add to Campaign action on any lead/contact"). ⭐ The two marketing pages disagree today — after your change they must agree. Read that line and match it rather than inventing a third phrasing.Scope
Three files:
content/docs/marketing/campaign-members.{mdx,zh-Hans.mdx,zh-Hant.mdx}. ⛔ No metadata change. ⛔ Nothing intest/**. ⛔ Nocontent/docs/releases/edit. ⛔ Do not re-open the sections PR #1846 just rewrote.zh-Hans takes the zh-CN pack's wording; zh-Hant mirrors in that page's own Traditional conventions — ⛔ never a mechanical conversion (ruled on PR #1846).
content/docs/analytics/**(#1847) andsrc/dashboards/executive.dashboard.ts(#1849). Neither meets your files.Proof obligation
Name every docs guard that reads this page and why each stays green — PR #1846 and PR #1850 both did this and it is the standard now;
docs-anchor-linksanddocs-locale-callouts(parity currently 1/1/1 after #1846) are the two most likely to move.pnpm verifystage by stage, exit codes captured before any pipe. Test-file / test counts before and after.Generated by Claude Code
os-steve commentedon Sep 10, 2026
os-dev-report
{ "issue": 1845, "status": "done", "branch": "claude/issue-1845-enrollment-paths-docs", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1855", "premise_still_valid": true, "summary": "Rewrote item 2 of 'How members get enrolled' on content/docs/marketing/campaign-members.{mdx,zh-Hans.mdx,zh-Hant.mdx} — one line per locale, nothing else in those files touched. It now names both entry points (Add to Campaign ships on a lead list and on a contact list), the surface each lives on (tick rows and run it from the selection toolbar, or run it on a single row from that row's row menu, choosing the campaign from a record picker), says plainly that opening a lead or a contact does not offer it, keeps the true half (there is still no Add Members picker on the campaign that reaches lead and contact list views), and carries the dedupe clause. Both PM claims re-verified independently before writing: two picker-driven paths (create_campaign at lead.actions.ts:141 and add_contact_to_campaign at contact.actions.ts:70, same label, same locations, same field-backed crm_campaign param, wired at lead.view.ts:204 / contact.view.ts:36), and no record-page reach (grep over src/pages/ returns nothing, exit 1). The dedupe difference was verified in the bodies, not taken on trust: the lead action reads fields ['crm_lead'] and builds its skip set from r.crm_lead, the contact action reads fields ['crm_contact'] and skips on r.crm_contact, so one person enrolled as a lead and again as a contact is deliberately two memberships. campaigns.mdx already carried the truthful phrasing and the new text matches it — that line is at :76 today, not :74 as the card cites (#1846 moved it two lines down). zh-Hans names the action as the zh-CN pack does (加入营销活动 — the one label both actions ship, objects.pipeline.ts:192 and objects.customer.ts:198), glossed after the English label in the pattern the page already uses for Related(相关)and New(新建); zh-Hant mirrors it in that page's own Traditional conventions (加入行銷活動, as sales/leads.zh-Hant.mdx:149 writes it), never a mechanical conversion. Changeset written, not skipped — product documentation ships to users. PR is ready for review, auto-merge not armed, nothing merged.", "tests": "pnpm verify stage by stage on the committed tree, every exit code captured before any pipe (cmd redirected to a file, EXIT=$? on the next statement): validate 0 · typecheck 0 · lint 0 (4 warnings / 12 suggestions, all pre-existing) · lint:i18n-gate 0 ('i18n lint gate: 0 i18n/missing-* issues') · hygiene 0 ('source hygiene clean') · hygiene:tokens 0 ('source token ratchet clean'; readings byte-identical to baseline — business semantics ~85,285, interaction layer ~38,335, authored total ~138,265, since that gate measures src/**/*.ts only) · build 0 (dist/objectstack.json 1916.9 KB) · test 0. Test counts: BEFORE 165 files, 3455 passed | 1 skipped (3456); AFTER 165 files, 3455 passed | 1 skipped (3456) — identical, and no test file added or removed (#1579's fence untouched). Guard surface was MEASURED, not reasoned: the full suite was re-run under a scratchpad-only vitest config aliasing node:fs to a facade that records every read of campaign-members*.mdx, attributed per test file (nothing in the repo touched; the alias breaks 7 unrelated fs-tooling suites, which is why it is a probe and not a commit). 15 guards read the page — docs-anchor-links, docs-locale-callouts (parity stays 1/1/1; no callout added or removed), docs-object-coverage, docs-object-term-consistency, docs-conversion-rate-spelling, docs-zh-hant-justification, docs-setup-navigation-names, docs-view-rosters, docs-metadata-counts, docs-drift, docs-retired-personas, docs-role-hierarchy, docs-runnable-samples, docs-analytics-vocabulary, docs-dashboard-tiles — with a per-guard reason for staying green in the PR body. Two more touch it without reading its body (docs-app-workflow-paths, path globs only; check-source-hygiene.mjs, control-byte scan over content/, green). Control-byte self-scan over the three pages and the changeset: grep -naP over the C0 range returns nothing (exit 1). No ablation leg: a prose-only docs change adds no assertion that could be mutated. CI on PR #1855 was still running at report time (mergeable_state unstable); main advanced one commit (#1852) after my branch point and git merge-tree reports 0 conflicts.", "mcp_calls": "9 — issue get, issue comments, PR #1846 read, dedupe search (empty) plus its control search (hit #1845/#597/#961, so the empty reading is real), issue create #1854, PR create, PR body read-back, this comment", "open_questions": [], "out_of_scope_findings": [ "filed as #1854: item 1 of the same list (campaign-members.mdx:14, three locales) and campaigns.mdx:69/:71 (three locales) say Campaign Enrollment runs itself — on the In Progress flip and on a Monday 9 AM cron. It is a type: 'screen' flow whose only entry point is the campaign's Enroll Members action (enroll_leads, record_header + record_more), and its own docblock records that the cron was removed because a cron firing seeds no inputs. Six files; deliberately not fixed here because this card's scope is item 2." ] }Generated by Claude Code