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 docs still describe the tracker fields and statuses #597 removed — three locales #1829
Found while working #1826. ⛔ Out of that card's scope — filed rather than fixed there.
The finding
content/docs/marketing/campaign-members.mdx (and its .zh-Hans.mdx / .zh-Hant.mdx siblings) documents a campaign member record that the metadata no longer declares.
"What the member record stores" lists First Opened and First Clicked. src/objects/campaign_member.object.ts declares exactly eight fields:
"The status is monotonic" table above it documents Opened, Clicked and Bounced as member statuses. The status picklist ships four values — sent, responded, converted, unsubscribed — and the locale packs carry labels for those four and no others.
The same page then builds on the phantom values: "Engagement tracking" promises that an email-tracking integration "writes back to the member record automatically" and "updates First Opened, First Clicked, status", and "How the metrics roll up" derives Open rate and Click rate as members where status >= Opened ÷ Sent. Neither rate can be computed from the shipped picklist, and no integration writes those fields because they are not there.
Where it came from
#597 ("Campaign member lifecycle: support contacts, live metrics, and trim tracker fields that have no writer") removed them from the object. The doc surface was not cleaned in the same move, so the page has been describing the pre-#597 record ever since. #961 corrected a different paragraph on this same page (the promised Members tab and the four member list views) without reaching these.
This is the drift class AGENTS.md documentation rule 5 is about — prose that hand-copies a machine roster and then outlives it.
What a fix looks like
Rewrite the three affected sections on all three locale pages against what the object actually declares: the four real statuses, the eight real fields, and a metrics section whose rates are the ones the four statuses can express. The zh-Hans page takes the zh-CN language-pack wording for the status nouns rather than coining fresh ones (documentation rule 6).
⚠️ Do not answer this by re-adding the fields — #597 removed them precisely because nothing wrote them, and its changeset is the record of that decision.
Refs: #597 (the removal) · #961 (the earlier correction on this page) · #1826 (where this was noticed).
Dispatched by the hotcrm PM seat (#1353). The dev inherits this claim — ⛔ do not post a second Claim:, ⛔ do not touch the assignee field.
Base off main at 62e23f5 or later (fetch origin/main fresh). Model: Opus, per 「没有 fable 派 opus」. Deliverable: one PR, ready for review, pnpm verify green. ⛔ Do not enable auto-merge and ⛔ do not merge — the PM arms the queue after reading your report.
PM verification — both halves measured on 62e23f5
The metadata side (what is true):src/objects/campaign_member.object.ts declares exactly 8 fields — member_number · crm_campaign · crm_lead · crm_contact · added_date · status · response_date · has_responded — and the status picklist ships exactly 4 values: sent (default) · responded · converted · unsubscribed. No tracker field, no opened/clicked/bounced.
The docs side (what the page says). In content/docs/marketing/campaign-members.mdx:
line
stale claim
heading ## The 7 member statuses
the object ships 4 — the number in the heading is itself wrong
:27 :28 :31
Opened / Clicked / Bounced rows in that table
:34
the monotonic sentence, built entirely on Clicked vs Opened
:56 :57
First Opened / First Clicked in "What the member record stores"
:65
"Email service → updates First Opened, First Clicked, status"
:77 :78
Open rate / Click rate formulas — uncomputable from the shipped picklist
:92
"Where members appear" leans on Bounced as a member subset
:97
"Opened and Clicked are vanity metrics"
campaign-members.zh-Hans.mdx and .zh-Hant.mdx carry the same rows at the same line numbers (27, 56, 57) and leave the status nouns in English inside a Chinese table. ⚠️ Those greps found the obvious sites by token; read all three pages end to end — a sentence that paraphrases a phantom status without naming it is the same defect.
The metrics section is a rewrite, not a deletion: give it the rates the four shipped statuses can express (response rate, conversion rate, unsubscribe rate over sent), and say plainly that open/click tracking is not part of this app. has_responded and response_date exist and are the honest basis.
Where the page promises an integration that "writes back automatically", the fix is to describe what actually writes each status — the object's own comment block at :201-207 names the writer of every one of the four (campaign_enrollment flow / create_campaign · the mark_responded action · campaign_lead_conversion_refresh · a rep or opt-out). Use it; do not invent.
Scope
Three files under content/docs/marketing/. ⛔ No metadata change, ⛔ no content/docs/releases/ edit.
If another docs page hand-copies the same phantom statuses, put it in out_of_scope_findings — do not widen the diff.
Check whether a docs guard (test/docs-drift.test.ts and neighbours) already pins any of the strings you are deleting, and report what you found either way.
{
"issue": 1829,
"status": "done",
"branch": "claude/issue-1829-campaign-member-docs",
"pr": "https://github.com/objectstack-ai/hotcrm/pull/1846",
"premise_still_valid": true,
"summary": "The premise held on every measured point: campaign_member.object.ts declares exactly 8 fields and 4 statuses (sent default / responded / converted / unsubscribed), and all three campaign-members pages described the pre-#597 record. Rewrote the same seven sections on each of the three locale pages against the object: the intro sentence and frontmatter description; 'The 7 member statuses' (now 4, each row naming its real writer from the object's writer block at :200-213, badge colours matching each option's declared color); the monotonic paragraph (replaced by what campaign_lead_conversion_refresh and campaign_member_lifecycle actually do); 'What the member record stores' (the 8 declared fields — First Opened / First Clicked out, the missing Member Number in); 'Engagement tracking' (now 'What is tracked, and what is not', a callout stating the app tracks no opens/clicks/bounces plus the four real writers); 'How the metrics roll up' (rewritten, not deleted: Sent = every member, Responses = members at Responded or Converted per the hook predicate, response rate = the object's response_rate formula, and the plain statement that unsubscribes are not a campaign counter because no such field exists); 'Where members appear' and all three tip blocks. Reading end to end found paraphrase sites the PM's greps could not: the frontmatter description promising engagement tracking, the intro sentence, the rep tip 'what content they've already engaged with', and the admin tip telling admins to install connectors for tracking that does not exist. zh-Hans takes the four status nouns from the zh-CN pack (已发送/已响应/已转化/已退订); zh-Hant mirrors it in that page's own Traditional wording (已發送/已回應/已轉化/已退訂). No metadata change, no re-added fields, locale policy untouched (#1767 neither resolved nor referenced). One changeset added (docs-only, but it ships to users as product documentation, so skip-changeset would have been the wrong call).",
"tests": "pnpm verify, every stage run separately with its exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?), on the committed tree at 568fb71: pnpm validate EXIT=0 · pnpm typecheck EXIT=0 · pnpm lint EXIT=0 (4 warnings, 12 suggestions, all pre-existing flow-approval findings) · pnpm lint:i18n-gate EXIT=0 ('i18n lint gate: 0 i18n/missing-* issues (16 total lint issue(s) reported, unaffected by this gate)') · pnpm hygiene EXIT=0 ('source hygiene clean' — its control-byte check is the one that reads content/, 572 files under content/.changeset/docs/.github/.claude) · pnpm hygiene:tokens EXIT=0 (that ratchet reads src/**/*.ts only, so a docs edit is outside its surface by construction) · pnpm build EXIT=0 · pnpm test EXIT=0. TEST COUNTS, before and after, identical: 165 test files passed (165); 3454 passed | 1 skipped (3455). No test was added or removed — a docs rewrite that no guard pins moves neither number, and that is itself the finding below. DOCS GUARDS, checked before deleting anything: no guard pins any string this PR removes — grep of test/ and scripts/ for 'Opened|Clicked|Bounced|Open rate|Click rate' returns only CasesOpenedByDayPriorityReport, an unrelated service report. The four guards that do read this page all stay green for a reason, not by luck: docs-object-coverage.test.ts:135 (ledger row crm_campaign_member -> marketing/campaign-members: page must exist in three locales, be listed in each meta*.json, and name the object — it still does), docs-anchor-links.test.ts (the page's one internal anchor, #how-the-metrics-roll-up, which is why that heading was deliberately kept verbatim), docs-locale-callouts.test.ts (blockquote-block parity across locales — the new callout was added to all three pages, 1/1/1), docs-conversion-rate-spelling.test.ts (the 转换率/轉換率 negative sweep over every Chinese page — not tripped; campaign-members is not one of its two CARRIERS). docs-drift.test.ts does not read this page at all. Also checked and clean: no raw control bytes in the three files (grep -naP over the control-byte class), and no metadata file touched.",
"mcp_calls": "7 — issue_read get, issue_read get_comments, one targeted search_issues (dedupe before filing), two issue_write creates, create_pull_request, this comment. The dedupe read went through repo-scoped REST first (anonymous GET repos/objectstack-ai/hotcrm/issues?state=open&labels=documentation returned HTTP 200, 8 open issues, no duplicate); the SEARCH channel is not reachable over REST in this container — api.github.com/search/issues answers 'This GitHub API path is not available: sessions are bound to their configured repositories' — so the one search went over MCP, declared here as a channel switch.",
"open_questions": [
{
"question": "Nothing in CI would have caught this drift, and nothing would catch it recurring. Should a docs guard pin a page's status roster against the object's picklist, the way docs-view-rosters.test.ts pins list-view rosters?",
"options": [
"A. Build it: a rule in the shape docs-view-rosters / docs-setup-navigation-names already use — a page that tabulates an object's statuses must name exactly the shipped option labels, derived from the registered stack, never hand-written. It would have gone red the day #597 landed and would red again on the next picklist trim.",
"B. Do not build it: AGENTS.md Scope rule 3 says tests here pin this repo's own business facts and warns against growing a gate farm, and documentation rule 5 says a docs page should not hand-copy a machine roster at all — a guard that pins the roster arguably blesses the copying.",
"C. Neither: fix it upstream as a platform drift-class capability, per Scope rules 1 and 3."
],
"recommendation": "A, scoped narrowly to status tables, and only if the PM wants the class closed rather than this instance. It is the same 'declared = enforced' shape five existing guards use, and the roster it would pin is exactly the kind a page cannot avoid stating once it documents statuses at all. I did not build it on this card: it is a new verification surface, nobody asked for it, and rule 3 makes it a decision rather than a chore."
},
{
"question": "zh-Hant status nouns: 已回應 (chosen) or 已響應 (a mechanical script conversion of the pack's 已响应)?",
"options": [
"A. 已回應 — what this page and 17 other zh-Hant pages already use for a response (響應 appears on only two, in unrelated senses: a response-time figure and a responsive layout). This is 'the page's own existing Traditional conventions', the wording the dispatch ruled for.",
"B. 已響應 — the character-by-character conversion of the zh-CN pack term, which is how docs-object-term-consistency.test.ts models its authored `hant` pins."
],
"recommendation": "A, as shipped — the dispatch ruled for the page's own conventions, and B would have put the only 響應 spelling of this concept in the whole zh-Hant corpus next to a 回應日期 cell on the same page. Flagged because the choice would need restating if crm_campaign_member is ever added to that guard's ledger; it is not in it today."
}
],
"out_of_scope_findings": [
"filed as #1844: the same #597 phantom tracker fields and rates survive on two other doc surfaces, six files — marketing/campaigns (:46 'Engagement (rollup) | Opens, clicks, responses, conversions', :81-82 'Open rate — % of members with status >= Opened' and 'Click rate'), and guides/email-and-calendar (:76 'a campaign member carries First Opened and First Clicked dates ... a person or an import fills them'), plus both Chinese siblings of each. Noted in that issue as lower confidence: marketing/index (:15) calls it 'their engagement status'.",
"filed as #1845: on the page I rewrote, 'How members get enrolled' item 2 calls the lead-side Add to Campaign 'the only picker-driven path that ships', but #597 also shipped add_contact_to_campaign — same label, same locations ['list_toolbar','list_item'], same field-backed picker, wired as bulkActions in contact.view.ts:36. Also measured: neither action is on a record header, so 'open a lead ... and run Add to Campaign' names a gesture the console does not offer, and campaigns.mdx:74 already states the truthful version. Deliberately NOT fixed in this PR: it is #961's own sentence in a section this card did not name, and the dispatch scoped the diff to the status/field/metrics defect.",
"not filed, reported for the PM: the local pnpm verify chain is green but CI on PR #1846 had not converged when this report was posted (mergeable_state: unstable, checks pending). Per the dispatch, CI convergence is the PM's read, not a wait I hold."
]
}
Activity
os-steve commentedon Sep 10, 2026
Claim:session_01DuzfS5chho38Yx1jxx9DEj→ branchclaude/issue-1829-campaign-member-docsDispatched by the hotcrm PM seat (#1353). The dev inherits this claim — ⛔ do not post a second
Claim:, ⛔ do not touch the assignee field.Base off
mainat62e23f5or later (fetchorigin/mainfresh).Model: Opus, per 「没有 fable 派 opus」.
Deliverable: one PR, ready for review,
pnpm verifygreen. ⛔ Do not enable auto-merge and ⛔ do not merge — the PM arms the queue after reading your report.PM verification — both halves measured on
62e23f5The metadata side (what is true):
src/objects/campaign_member.object.tsdeclares exactly 8 fields —member_number·crm_campaign·crm_lead·crm_contact·added_date·status·response_date·has_responded— and thestatuspicklist ships exactly 4 values:sent(default) ·responded·converted·unsubscribed. No tracker field, noopened/clicked/bounced.The docs side (what the page says). In
content/docs/marketing/campaign-members.mdx:## The 7 member statusesOpened/Clicked/Bouncedrows in that tableFirst Opened/First Clickedin "What the member record stores"Open rate/Click rateformulas — uncomputable from the shipped picklistcampaign-members.zh-Hans.mdxand.zh-Hant.mdxcarry the same rows at the same line numbers (27, 56, 57) and leave the status nouns in English inside a Chinese table.Rulings you do not need to re-derive
has_respondedandresponse_dateexist and are the honest basis.:201-207names the writer of every one of the four (campaign_enrollmentflow /create_campaign· themark_respondedaction ·campaign_lead_conversion_refresh· a rep or opt-out). Use it; do not invent.Scope
content/docs/marketing/. ⛔ No metadata change, ⛔ nocontent/docs/releases/edit.out_of_scope_findings— do not widen the diff.test/docs-drift.test.tsand neighbours) already pins any of the strings you are deleting, and report what you found either way.Generated by Claude Code
os-steve commentedon Sep 10, 2026
os-dev-report
{ "issue": 1829, "status": "done", "branch": "claude/issue-1829-campaign-member-docs", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1846", "premise_still_valid": true, "summary": "The premise held on every measured point: campaign_member.object.ts declares exactly 8 fields and 4 statuses (sent default / responded / converted / unsubscribed), and all three campaign-members pages described the pre-#597 record. Rewrote the same seven sections on each of the three locale pages against the object: the intro sentence and frontmatter description; 'The 7 member statuses' (now 4, each row naming its real writer from the object's writer block at :200-213, badge colours matching each option's declared color); the monotonic paragraph (replaced by what campaign_lead_conversion_refresh and campaign_member_lifecycle actually do); 'What the member record stores' (the 8 declared fields — First Opened / First Clicked out, the missing Member Number in); 'Engagement tracking' (now 'What is tracked, and what is not', a callout stating the app tracks no opens/clicks/bounces plus the four real writers); 'How the metrics roll up' (rewritten, not deleted: Sent = every member, Responses = members at Responded or Converted per the hook predicate, response rate = the object's response_rate formula, and the plain statement that unsubscribes are not a campaign counter because no such field exists); 'Where members appear' and all three tip blocks. Reading end to end found paraphrase sites the PM's greps could not: the frontmatter description promising engagement tracking, the intro sentence, the rep tip 'what content they've already engaged with', and the admin tip telling admins to install connectors for tracking that does not exist. zh-Hans takes the four status nouns from the zh-CN pack (已发送/已响应/已转化/已退订); zh-Hant mirrors it in that page's own Traditional wording (已發送/已回應/已轉化/已退訂). No metadata change, no re-added fields, locale policy untouched (#1767 neither resolved nor referenced). One changeset added (docs-only, but it ships to users as product documentation, so skip-changeset would have been the wrong call).", "tests": "pnpm verify, every stage run separately with its exit code captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?), on the committed tree at 568fb71: pnpm validate EXIT=0 · pnpm typecheck EXIT=0 · pnpm lint EXIT=0 (4 warnings, 12 suggestions, all pre-existing flow-approval findings) · pnpm lint:i18n-gate EXIT=0 ('i18n lint gate: 0 i18n/missing-* issues (16 total lint issue(s) reported, unaffected by this gate)') · pnpm hygiene EXIT=0 ('source hygiene clean' — its control-byte check is the one that reads content/, 572 files under content/.changeset/docs/.github/.claude) · pnpm hygiene:tokens EXIT=0 (that ratchet reads src/**/*.ts only, so a docs edit is outside its surface by construction) · pnpm build EXIT=0 · pnpm test EXIT=0. TEST COUNTS, before and after, identical: 165 test files passed (165); 3454 passed | 1 skipped (3455). No test was added or removed — a docs rewrite that no guard pins moves neither number, and that is itself the finding below. DOCS GUARDS, checked before deleting anything: no guard pins any string this PR removes — grep of test/ and scripts/ for 'Opened|Clicked|Bounced|Open rate|Click rate' returns only CasesOpenedByDayPriorityReport, an unrelated service report. The four guards that do read this page all stay green for a reason, not by luck: docs-object-coverage.test.ts:135 (ledger row crm_campaign_member -> marketing/campaign-members: page must exist in three locales, be listed in each meta*.json, and name the object — it still does), docs-anchor-links.test.ts (the page's one internal anchor, #how-the-metrics-roll-up, which is why that heading was deliberately kept verbatim), docs-locale-callouts.test.ts (blockquote-block parity across locales — the new callout was added to all three pages, 1/1/1), docs-conversion-rate-spelling.test.ts (the 转换率/轉換率 negative sweep over every Chinese page — not tripped; campaign-members is not one of its two CARRIERS). docs-drift.test.ts does not read this page at all. Also checked and clean: no raw control bytes in the three files (grep -naP over the control-byte class), and no metadata file touched.", "mcp_calls": "7 — issue_read get, issue_read get_comments, one targeted search_issues (dedupe before filing), two issue_write creates, create_pull_request, this comment. The dedupe read went through repo-scoped REST first (anonymous GET repos/objectstack-ai/hotcrm/issues?state=open&labels=documentation returned HTTP 200, 8 open issues, no duplicate); the SEARCH channel is not reachable over REST in this container — api.github.com/search/issues answers 'This GitHub API path is not available: sessions are bound to their configured repositories' — so the one search went over MCP, declared here as a channel switch.", "open_questions": [ { "question": "Nothing in CI would have caught this drift, and nothing would catch it recurring. Should a docs guard pin a page's status roster against the object's picklist, the way docs-view-rosters.test.ts pins list-view rosters?", "options": [ "A. Build it: a rule in the shape docs-view-rosters / docs-setup-navigation-names already use — a page that tabulates an object's statuses must name exactly the shipped option labels, derived from the registered stack, never hand-written. It would have gone red the day #597 landed and would red again on the next picklist trim.", "B. Do not build it: AGENTS.md Scope rule 3 says tests here pin this repo's own business facts and warns against growing a gate farm, and documentation rule 5 says a docs page should not hand-copy a machine roster at all — a guard that pins the roster arguably blesses the copying.", "C. Neither: fix it upstream as a platform drift-class capability, per Scope rules 1 and 3." ], "recommendation": "A, scoped narrowly to status tables, and only if the PM wants the class closed rather than this instance. It is the same 'declared = enforced' shape five existing guards use, and the roster it would pin is exactly the kind a page cannot avoid stating once it documents statuses at all. I did not build it on this card: it is a new verification surface, nobody asked for it, and rule 3 makes it a decision rather than a chore." }, { "question": "zh-Hant status nouns: 已回應 (chosen) or 已響應 (a mechanical script conversion of the pack's 已响应)?", "options": [ "A. 已回應 — what this page and 17 other zh-Hant pages already use for a response (響應 appears on only two, in unrelated senses: a response-time figure and a responsive layout). This is 'the page's own existing Traditional conventions', the wording the dispatch ruled for.", "B. 已響應 — the character-by-character conversion of the zh-CN pack term, which is how docs-object-term-consistency.test.ts models its authored `hant` pins." ], "recommendation": "A, as shipped — the dispatch ruled for the page's own conventions, and B would have put the only 響應 spelling of this concept in the whole zh-Hant corpus next to a 回應日期 cell on the same page. Flagged because the choice would need restating if crm_campaign_member is ever added to that guard's ledger; it is not in it today." } ], "out_of_scope_findings": [ "filed as #1844: the same #597 phantom tracker fields and rates survive on two other doc surfaces, six files — marketing/campaigns (:46 'Engagement (rollup) | Opens, clicks, responses, conversions', :81-82 'Open rate — % of members with status >= Opened' and 'Click rate'), and guides/email-and-calendar (:76 'a campaign member carries First Opened and First Clicked dates ... a person or an import fills them'), plus both Chinese siblings of each. Noted in that issue as lower confidence: marketing/index (:15) calls it 'their engagement status'.", "filed as #1845: on the page I rewrote, 'How members get enrolled' item 2 calls the lead-side Add to Campaign 'the only picker-driven path that ships', but #597 also shipped add_contact_to_campaign — same label, same locations ['list_toolbar','list_item'], same field-backed picker, wired as bulkActions in contact.view.ts:36. Also measured: neither action is on a record header, so 'open a lead ... and run Add to Campaign' names a gesture the console does not offer, and campaigns.mdx:74 already states the truthful version. Deliberately NOT fixed in this PR: it is #961's own sentence in a section this card did not name, and the dispatch scoped the diff to the status/field/metrics defect.", "not filed, reported for the PM: the local pnpm verify chain is green but CI on PR #1846 had not converged when this report was posted (mergeable_state: unstable, checks pending). Per the dispatch, CI convergence is the PM's read, not a wait I hold." ] }Generated by Claude Code