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 created directly (panel New, import, REST) skip the closed-campaign and email-opt-out gates that Add to Campaign and Enroll Members now apply #2344
#2336 (PR #2343, 65507bd) put both enrollment gates into the one enroll door, campaign_lead_member_enroll and campaign_contact_member_enroll. Every action that enrolls a person goes through that door. Its os-dev report (6451691064) measured a second way in that skips it:
POST /api/v1/data/crm_campaign_member as a marketing_user, with crm_campaign set to a Completed campaign and crm_lead set to an opted-out lead, answered 201 (one-off run on fa2c7a2).
The Campaign Members related list's New, a data import and REST all create the member without the enroll flows. So a Completed campaign's metrics still move, and an opted-out person can still be enrolled, by hand.
The enroll-flow header says every enrollment door reaches the insert through those flows. This route makes that untrue.
Target
A user's direct create of a crm_campaign_member is refused, with the same two reasons and the same wording as the flows:
the campaign is not Planning or In Progress;
the Lead or Contact has Email Opt Out ticked.
System writes still pass: seeds, the enroll flows' own insert, and the conversion promotion. Pick the smallest mechanism that holds on 18.2.0, for example a beforeInsert guard on user writes, and say why it was chosen. ⛔ Do not copy the gate into a second place that can drift: the flows and the object should read one predicate, or a test should hold the two copies together.
File surface
src/marketing/objects/campaign_member.object.ts and its hook (new or existing), the translation packs for any new message, tests beside it, and one changeset.
Acceptance
The 201 above becomes a refusal, for both reasons, from REST and from the panel's New. Tests pin both.
This order takes the slot freed by #2326 (PR #2345, ACCEPTed with auto-merge armed). Irene asked for dispatch to continue (「继续派发」) at lane concurrency 5.
Premise:#2336 landed as 65507bd. Its os-dev report (6451691064) measured a 201 for a direct POST /api/v1/data/crm_campaign_member by a marketing_user, with a Completed campaign and an opted-out lead. Re-measure that on origin/main before editing.
Container & model: M · mode:thread · model TIER_DEFAULT · effort high (the thread runs at the project default). Role file:objectstack-ai/objectstack.claude/agents/os-dev.md. Read it in full. hotcrm AGENTS.md binds. Re-measure every file:line against origin/main (65507bd or later). Writes:
The PR goes only through the relay op pr_create (draft, base main, assignees: [huangyiirene]).
Comments go only through scripts/pm/post-stamped.mjs.
⛔ No GitHub MCP write. ⛔ No issue filing: out-of-scope findings go in the report.
The write budget is tight, so make two writes: the PR and the report.
Report: the os-dev-report JSON, as a comment on this card and as the final message to the seat thread. ⛔ Start editing only after the newest Claim: on this card names your branch. Push work in progress at each milestone, so a stop loses nothing.
Ruling (not to be re-decided)
A user's direct create of a campaign member is refused for the same two reasons as the enroll flows, with the same wording, reusing the flows' translated messages where the mechanism allows:
a campaign that is not Planning or In Progress, or no longer exists;
a Lead or Contact with Email Opt Out ticked.
These writes still pass:
system writes: seeds, imports run as system, and campaign_lead_conversion_refresh;
the enroll flows' own insert.
The refusal code follows the house refusal envelope. Name the code you chose, and say why.
⛔ Do not leave two predicates that can drift. Either the flows and the object share one source, or a test holds the copies together, as deal-threshold-parity does.
An update that moves an existing member to another campaign or person is in scope only if the same guard covers it for free. Say whether it does.
PM mechanism assumptions (measure first, on the installed 18.2.0)
The enroll flows insert as the caller, so a user-only guard would also catch the flows' own insert. Measure how a hook tells the flow insert apart. ctx.provenance.flowRunId is how feat(account): an account Convert Lead creates skips account approval #2337 told a flow run apart. If nothing tells them apart, the flows already refused first, so a second identical refusal is harmless. Say which case holds.
The panel's New on the campaign's Campaign Members related list shows the refusal in its dialog.
Suggested route (optional)
Tests: a REST create for each refusal reason as a marketing_user, plus controls for an open campaign, an opted-in person and a system write.
An ablation.
A browser re-check of the panel's New.
A changeset.
Serial constraints: none known. #2338 items 5–13 and #2334 do not touch campaign_member. Merge origin/main before the PR.
Claim: PM loop round 1
Session: session_01J8VVAnPkgK6CWrJa6t7Fk5
Account: huangyiirene
Branch: claude/project-thread-cf12dw
Worktree: hotcrm-issue-2344 (card thread; branch is platform-designated, the measured platform exception to claude/issue-N-slug; implementing session session_01J8VVAnCuJoiB6P78EsvrL5)
Domain: repo:hotcrm (single-lane repo, no domain:*)
Seat: repo:hotcrm#1
File surface: src/marketing/objects/campaign_member.object.ts and its hook (new or existing), a shared predicate module if the flows and the object share one (then src/marketing/flows/campaign-member-enroll.flow.ts too), translation packs for any new message, tests under test/, one changeset (stop on breach; explain in the report)
Container & model: M, mode:thread, model: TIER_DEFAULT (sister repo, hand-derived), effort: the order asked high; the thread runs at the project default, which only the maintainer sets
Clause-②: no
Metadata-protocol: none
Responsibility: refuse a user's direct campaign-member create for a closed campaign or an opted-out person, as ruled in the order
Thread-read: 6455703179
Premise: holds — re-read at claim on hotcrm origin/main0f17c6c (order comment 6455703179)
Serial constraints cleared: none known; merge origin/main before the PR
{
"issue": 2344,
"status": "done",
"branch": "claude/project-thread-cf12dw",
"pr": "https://github.com/objectstack-ai/hotcrm/pull/2351",
"session": "session_01J8VVAnCuJoiB6P78EsvrL5",
"premise_still_valid": true,
"summary": "Premise re-measured at 0f17c6c: a marketing_user's POST /api/v1/data/crm_campaign_member answered 201 for Completed+opted-out, Completed+opted-in and In Progress+opted-out. Added campaign_member_enrollment_gate (beforeInsert/beforeUpdate, priority 100, lowered) on crm_campaign_member: skips writes with no ctx.user (seed, system import, campaign_lead_conversion_refresh) and updates that do not move the member; refuses a campaign not planning/in_progress or missing with VALIDATION_FAILED/400 (invalid_value: the field's lookupFilters already declare only open campaigns) and an email_opt_out lead/contact with FORBIDDEN/403 (prohibited: compliance flag, same code as the Do Not Call guards), using the enroll flows' exact English sentences. Mechanism assumption 1: the flow insert IS distinguishable (ctx.provenance.flowRunId + ctx.user; REST has user and no provenance; seed has session.isSystem and no user) but is deliberately not exempted, since the flow already passed the same check and an exemption would let any future member-writing flow skip both gates. Assumption 2 holds: the panel's New shows the refusal in its dialog (shipped lowered body, browser). Moving updates (new campaign/lead/contact) are covered by the same guard. The two predicate copies (flow CEL, hook JS) are held together by a parity test driving both doors over every declared campaign status and both opt-out states, asserting the same verdict and identical message. Flow translations cannot be reused: 18.2.0 has no translation key for a hook refusal, so the direct door answers in English in every locale (stated in the changeset).",
"tests": "test/campaign-member-direct-create-gates.test.ts (new, 19/19 pass): parity matrix flow door vs REST over planning/in_progress/completed/aborted x lead/contact (verdict + userMessage == refusalMessage + no row); opted-out lead/contact 403 FORBIDDEN in the flow's words; closed campaign reported before opt-out; controls (seed write stored, Responded on a Completed member with form re-sending campaign+lead 200, Unsubscribed on opted-out lead's member 200); moving edits 400/403 with member unchanged; lowered body via runShippedHook (SandboxError VALIDATION_FAILED/400, FORBIDDEN/403; passes open+opted-in and no-user). Full suite at 10f30f1 (after merging origin/main a31d91a): Test Files 233 passed (233), Tests 4509 passed | 1 skipped (4510). Head 7575147 adds only the changeset's two declaration lines. Ablation 1 (guard short-circuited, anchor 1, marker 0->1, restored via git checkout HEAD, git diff HEAD empty, blob hash = HEAD): MUTANT_EXIT=1, Tests 10 failed | 9 passed (19). Ablation 2 (flow CAMPAIGN_OPEN widened to completed, hook untouched): MUTANT_EXIT=1, Tests 2 failed | 17 passed (the two completed parity cases); a first attempt with a CEL block-comment marker unregistered the flow ('Flow campaign_lead_member_enroll not found') and was discarded as a null run. Browser (dist/objectstack.json served with objectstack dev -a, lowered body): CPG-0007 Completed -> Campaign Members New -> 400 dialog with the campaign sentence; CPG-0001 + Leo Vance -> 403 dialog with the opt-out sentence; control saves CM-00052; zh-CN shows the English sentence. Screenshots in project files issue-2344/.",
"gates": "Local at 10f30f1, each exit captured before any pipe: pnpm validate 0, pnpm typecheck 0, pnpm lint (objectstack lint --strict) 0, pnpm hygiene 0, pnpm hygiene:tokens 0 ('source token ratchet clean'), pnpm build 0, vitest full 0. hotcrm has no scripts/pm/dispatch-gates.mjs; families hand-derived from package.json verify chain, all run. PR #2351 check-runs at report time: Vercel Preview Comments success, Label Pull Request success; Analyze Code (javascript), Check Changeset, link-check, Quality Checks, Build and Test (22.x), Playwright in_progress.",
"dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
"mcp_calls": "5 — all reads: issue_read get #2344, issue_read get_comments #2344 (x2), pull_request_read get #2343 (house PR layout), pull_request_read get_check_runs #2351. No MCP write.",
"api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/hotcrm/pulls draft + POST /issues/2351/assignees, read-back identical 11507 bytes, assignee huangyiirene); this os-dev-report comment via post-stamped.mjs (POST /repos/objectstack-ai/hotcrm/issues/2344/comments). git push is not counted.",
"open_questions": [],
"out_of_scope_findings": [
"class: a (user-visible) · Seam: spec:translation hook refusal key (none) → runtime: hook refusal userMessage | renderer: record form dialog error banner · reach: the console's New on a campaign's Campaign Members panel in zh-CN shows the English refusal sentence (measured, screenshot issue-2344/04-zh-CN-completed-campaign-new.png); 18.2.0's translation schema keys object _validations and flow refusals but has no key for a hook's refusal, so every hotcrm hook refusal reaches non-English users in English · dedupe words: hook refusal translation, userMessage i18n, translation key hook, localized refusal, _validations hooks",
"carrier: seat — campaign-member-enroll.flow.ts header still says every enrollment door reaches the insert only through the two flows; outside this claim's file surface, a one-sentence edit would cite campaign_member_enrollment_gate · noted, not filed",
"carrier: seat — content/docs/marketing/campaign-members (en, zh-Hans, zh-Hant) names only Enroll Members and Add to Campaign as holding the two rules; New/import/REST now hold them too; outside this claim's file surface · noted, not filed"
]
}
Merged 2026-10-28T00:05Z as 1f1ea12, from head 6dfd7230e2. That is the head the seat ACCEPTed (6456805794).
main CI on 1f1ea12
GET /commits/1f1ea12/check-runs
All 7 runs report success.
State: the PR's closing line closed this card as completed, and its lane label is removed in this stroke. When a user creates a campaign member by hand (New on the panel, an import, or REST), it now meets the same two gates as Add to Campaign and Enroll Members. The refusal sentence is English in every locale until objectstack#26170 lands.
Activity
objectstack-fleet commentedon Oct 27, 2026
Dispatch order · #2344 ·
repo:hotcrmseat 1 ·huangyiirene· sessionsession_01J8VVAnPkgK6CWrJa6t7Fk5· 2026-10-27T23:01ZThis order takes the slot freed by #2326 (PR #2345, ACCEPTed with auto-merge armed). Irene asked for dispatch to continue (「继续派发」) at lane concurrency 5.
Premise: #2336 landed as
65507bd. Its os-dev report (6451691064) measured a 201 for a directPOST /api/v1/data/crm_campaign_memberby amarketing_user, with a Completed campaign and an opted-out lead. Re-measure that onorigin/mainbefore editing.Container & model: M ·
mode:thread· modelTIER_DEFAULT· efforthigh(the thread runs at the project default).Role file:
objectstack-ai/objectstack.claude/agents/os-dev.md. Read it in full. hotcrmAGENTS.mdbinds. Re-measure every file:line againstorigin/main(65507bdor later).Writes:
pr_create(draft, basemain,assignees: [huangyiirene]).scripts/pm/post-stamped.mjs.Report: the
os-dev-reportJSON, as a comment on this card and as the final message to the seat thread. ⛔ Start editing only after the newestClaim:on this card names your branch. Push work in progress at each milestone, so a stop loses nothing.Ruling (not to be re-decided)
campaign_lead_conversion_refresh;deal-threshold-paritydoes.PM mechanism assumptions (measure first, on the installed 18.2.0)
ctx.provenance.flowRunIdis how feat(account): an account Convert Lead creates skips account approval #2337 told a flow run apart. If nothing tells them apart, the flows already refused first, so a second identical refusal is harmless. Say which case holds.Suggested route (optional)
marketing_user, plus controls for an open campaign, an opted-in person and a system write.Serial constraints: none known. #2338 items 5–13 and #2334 do not touch
campaign_member. Mergeorigin/mainbefore the PR.Generated by Claude Code
objectstack-fleet commentedon Oct 27, 2026
Claim: PM loop round 1
Session:
session_01J8VVAnPkgK6CWrJa6t7Fk5Account:
huangyiireneBranch:
claude/project-thread-cf12dwWorktree:
hotcrm-issue-2344(card thread; branch is platform-designated, the measured platform exception toclaude/issue-N-slug; implementing sessionsession_01J8VVAnCuJoiB6P78EsvrL5)Domain:
repo:hotcrm(single-lane repo, nodomain:*)Seat:
repo:hotcrm#1File surface:
src/marketing/objects/campaign_member.object.tsand its hook (new or existing), a shared predicate module if the flows and the object share one (thensrc/marketing/flows/campaign-member-enroll.flow.tstoo), translation packs for any new message, tests undertest/, one changeset (stop on breach; explain in the report)Container & model:
M,mode:thread,model: TIER_DEFAULT (sister repo, hand-derived),effort: the order asked high; the thread runs at the project default, which only the maintainer setsClause-②: no
Metadata-protocol: none
Responsibility: refuse a user's direct campaign-member create for a closed campaign or an opted-out person, as ruled in the order
Thread-read: 6455703179
Premise: holds — re-read at claim on hotcrm
origin/main0f17c6c(order comment 6455703179)Serial constraints cleared: none known; merge
origin/mainbefore the PRobjectstack-fleet commentedon Oct 27, 2026
os-dev-report
{ "issue": 2344, "status": "done", "branch": "claude/project-thread-cf12dw", "pr": "https://github.com/objectstack-ai/hotcrm/pull/2351", "session": "session_01J8VVAnCuJoiB6P78EsvrL5", "premise_still_valid": true, "summary": "Premise re-measured at 0f17c6c: a marketing_user's POST /api/v1/data/crm_campaign_member answered 201 for Completed+opted-out, Completed+opted-in and In Progress+opted-out. Added campaign_member_enrollment_gate (beforeInsert/beforeUpdate, priority 100, lowered) on crm_campaign_member: skips writes with no ctx.user (seed, system import, campaign_lead_conversion_refresh) and updates that do not move the member; refuses a campaign not planning/in_progress or missing with VALIDATION_FAILED/400 (invalid_value: the field's lookupFilters already declare only open campaigns) and an email_opt_out lead/contact with FORBIDDEN/403 (prohibited: compliance flag, same code as the Do Not Call guards), using the enroll flows' exact English sentences. Mechanism assumption 1: the flow insert IS distinguishable (ctx.provenance.flowRunId + ctx.user; REST has user and no provenance; seed has session.isSystem and no user) but is deliberately not exempted, since the flow already passed the same check and an exemption would let any future member-writing flow skip both gates. Assumption 2 holds: the panel's New shows the refusal in its dialog (shipped lowered body, browser). Moving updates (new campaign/lead/contact) are covered by the same guard. The two predicate copies (flow CEL, hook JS) are held together by a parity test driving both doors over every declared campaign status and both opt-out states, asserting the same verdict and identical message. Flow translations cannot be reused: 18.2.0 has no translation key for a hook refusal, so the direct door answers in English in every locale (stated in the changeset).", "tests": "test/campaign-member-direct-create-gates.test.ts (new, 19/19 pass): parity matrix flow door vs REST over planning/in_progress/completed/aborted x lead/contact (verdict + userMessage == refusalMessage + no row); opted-out lead/contact 403 FORBIDDEN in the flow's words; closed campaign reported before opt-out; controls (seed write stored, Responded on a Completed member with form re-sending campaign+lead 200, Unsubscribed on opted-out lead's member 200); moving edits 400/403 with member unchanged; lowered body via runShippedHook (SandboxError VALIDATION_FAILED/400, FORBIDDEN/403; passes open+opted-in and no-user). Full suite at 10f30f1 (after merging origin/main a31d91a): Test Files 233 passed (233), Tests 4509 passed | 1 skipped (4510). Head 7575147 adds only the changeset's two declaration lines. Ablation 1 (guard short-circuited, anchor 1, marker 0->1, restored via git checkout HEAD, git diff HEAD empty, blob hash = HEAD): MUTANT_EXIT=1, Tests 10 failed | 9 passed (19). Ablation 2 (flow CAMPAIGN_OPEN widened to completed, hook untouched): MUTANT_EXIT=1, Tests 2 failed | 17 passed (the two completed parity cases); a first attempt with a CEL block-comment marker unregistered the flow ('Flow campaign_lead_member_enroll not found') and was discarded as a null run. Browser (dist/objectstack.json served with objectstack dev -a, lowered body): CPG-0007 Completed -> Campaign Members New -> 400 dialog with the campaign sentence; CPG-0001 + Leo Vance -> 403 dialog with the opt-out sentence; control saves CM-00052; zh-CN shows the English sentence. Screenshots in project files issue-2344/.", "gates": "Local at 10f30f1, each exit captured before any pipe: pnpm validate 0, pnpm typecheck 0, pnpm lint (objectstack lint --strict) 0, pnpm hygiene 0, pnpm hygiene:tokens 0 ('source token ratchet clean'), pnpm build 0, vitest full 0. hotcrm has no scripts/pm/dispatch-gates.mjs; families hand-derived from package.json verify chain, all run. PR #2351 check-runs at report time: Vercel Preview Comments success, Label Pull Request success; Analyze Code (javascript), Check Changeset, link-check, Quality Checks, Build and Test (22.x), Playwright in_progress.", "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm", "mcp_calls": "5 — all reads: issue_read get #2344, issue_read get_comments #2344 (x2), pull_request_read get #2343 (house PR layout), pull_request_read get_check_runs #2351. No MCP write.", "api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/hotcrm/pulls draft + POST /issues/2351/assignees, read-back identical 11507 bytes, assignee huangyiirene); this os-dev-report comment via post-stamped.mjs (POST /repos/objectstack-ai/hotcrm/issues/2344/comments). git push is not counted.", "open_questions": [], "out_of_scope_findings": [ "class: a (user-visible) · Seam: spec:translation hook refusal key (none) → runtime: hook refusal userMessage | renderer: record form dialog error banner · reach: the console's New on a campaign's Campaign Members panel in zh-CN shows the English refusal sentence (measured, screenshot issue-2344/04-zh-CN-completed-campaign-new.png); 18.2.0's translation schema keys object _validations and flow refusals but has no key for a hook's refusal, so every hotcrm hook refusal reaches non-English users in English · dedupe words: hook refusal translation, userMessage i18n, translation key hook, localized refusal, _validations hooks", "carrier: seat — campaign-member-enroll.flow.ts header still says every enrollment door reaches the insert only through the two flows; outside this claim's file surface, a one-sentence edit would cite campaign_member_enrollment_gate · noted, not filed", "carrier: seat — content/docs/marketing/campaign-members (en, zh-Hans, zh-Hant) names only Enroll Members and Add to Campaign as holding the two rules; New/import/REST now hold them too; outside this claim's file surface · noted, not filed" ] }Generated by Claude Code
objectstack-fleet commentedon Oct 28, 2026
Landed ·
repo:hotcrmseat 1 · sessionsession_01J8VVAnPkgK6CWrJa6t7Fk5· 2026-10-28T04:1xZGET /pulls/23511f1ea12, from head6dfd7230e2. That is the head the seat ACCEPTed (6456805794).mainCI on1f1ea12GET /commits/1f1ea12/check-runssuccess.State: the PR's closing line closed this card as
completed, and its lane label is removed in this stroke. When a user creates a campaign member by hand (New on the panel, an import, or REST), it now meets the same two gates as Add to Campaign and Enroll Members. The refusal sentence is English in every locale until objectstack#26170 lands.Generated by Claude Code