Skip to content

fix(marketing): every enrollment door writes as the caller, so a profile without create on campaign members cannot enroll - #2186

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-2167-enrollment-enforces-profile
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-2167-enrollment-enforces-profile

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #2167
Refs #2060 (item 8, the Enroll Members half only)
Clause-②: no

Every enrollment door now writes as the person who runs it, so crm_campaign_member's own allowCreate decides who enrolls. This executes the card's rulings: Q1 letter B (6266676306) and Q2 letter a (6267365020), on top of the letter-A outcome (6257118101 items 1, 3 and 4). No platform card, and src/sales/profiles/*.profile.ts is unchanged.

This is a takeover. The first commit (e944026c) is the previous dev's, pushed before that run died; this PR reviews it hunk by hunk, merges origin/main (b6cadbd5), and adds the fixes, tests, docs and changeset it lacked.

What changed

door before after
Enroll Members (campaign_enrollment) per-member insert in two runAs: 'system' sub-flows; a refused member was swallowed by the guarded iteration and the run reported success, with no result message the two per-person flows run runAs: 'user' and own the already-enrolled check; the guarded try and catch count enrolled and refused people; any refusal ends the run refused with both counts, and a clean run ends completed with the static successMessage
Add to Campaign on a lead (create_campaign) and a contact (add_contact_to_campaign) elevated script bodies type: 'flow' actions over the side's one enroll-one-person flow (recordIdParam = the flow's person input, param campaignId)
Mark Responded (mark_responded) elevated script body operation: 'update' + patch: { status: 'responded' }, written as the caller; campaign_member_lifecycle stamps has_responded / response_date
visible no permission term the three enrollment doors add current_user.can("crm_campaign_member", "create"); Mark Responded adds current_user.can("crm_campaign_member", "edit")

The inherited commit, hunk by hunk

  • Kept as written: both callees to runAs: 'user' with the dedupe moved into them and an enrolled output (campaign-member-enroll.flow.ts); the subflow output, counters, check_refused gateway, end_refused node and flow successMessage (campaign-enrollment.flow.ts); the two flow actions, their campaignId params and visible terms (lead.actions.ts, contact.actions.ts); Enroll Members' visible and the operation: 'update' Mark Responded (campaign.actions.ts); the 15 locale-pack edits (the param key follows the param's new name; the run's two messages move from the action to the flow).
  • Changed: the per-person result edges b2 / c2 read the callee's output map with both has(vars.…) guards, and the refused/clean pair e10 / e11 is the equality pair != 0 / == 0. On the inherited head, test/flow-variable-conditions.test.ts was red on those three edges: {"leadEnrolment":null}: No such key: enrolled, and {"refusedCount":null}: no such overload, a null ordered against an int.
  • Added: the inherited commit carried no test change, and the inherited head failed 17 tests across 6 files (listed under Tests). This PR rewrites those pins for the new doors and adds test/campaign-enrollment-doors.test.ts.
  • Outside the claim's file surface, kept and reported: src/sales/flows/_guarded-iteration.ts gains an optional third argument, the catch handler's assignments. Q2 letter a puts the counters "inside the guarded try and catch", and that helper builds the try and catch. For every two-argument caller the output is byte-identical (measured: JSON.stringify of the old and the new guarded('k', region) are equal).

Tests

All of the following ran on c8a212b1, the final head, through the shared verify lock.

  • pnpm validate exit 0 (✓ Validation passed) · pnpm typecheck exit 0 · pnpm lint exit 0 (16 suggestions, unchanged) · pnpm lint:i18n-gate exit 0 (0 i18n/missing-* issues) · pnpm hygiene exit 0 · pnpm hygiene:tokens exit 0 (src/marketing authored total ~9,730 tokens, ceiling ~15,000) · pnpm build exit 0.

  • vitest run --shard=k/3 --maxWorkers=2: 1/3 67 files, 1104 passed · 2/3 66 files, 1258 passed, 1 skipped · 3/3 66 files, 1594 passed. Each shard's lock verdict was command-exit 0.

  • The inherited head (9b7ae935, the merge before this PR's fixes) failed 17 tests: 6 in script-bodies, 3 in actions-flows-integrity, 1 in audit-stamp-readonly, 1 in bulk-action-dispatch, 2 in campaign-member-lifecycle and 4 in flow-variable-conditions.

  • New, test/campaign-enrollment-doors.test.ts (29 tests). For sales_rep and sales_manager:

    • the data-door insert is refused (403 PERMISSION_DENIED, the control);
    • Add to Campaign on a lead and on a contact is refused (400 FLOW_FAILED, the failed node being the insert), and no member is written;
    • Enroll Members on leads and on contacts ends refused with 0 enrolled, and the campaign has no member;
    • Mark Responded is refused (403 PERMISSION_DENIED), and the member stays at Sent.

    For marketing_user and the admin:

    • both Add to Campaign doors enroll, with created_by = the caller, and a second run answers enrolled: false and writes nothing;
    • a clean Enroll Members run is a terminal success whose successMessage is the flow's static one;
    • Mark Responded records the response, and the hook stamps the response fields.

    A partly refused run stages one refused insert through recordEngineWrites. It ends refused, the message equals Enrollment refused for 1 of the people selected: N enrolled, 1 refused. … with N the stored member count, and the refused lead is absent. Each door's visible is evaluated with @objectstack/formula against the server's own GET /api/v1/auth/me/permissions per persona: hidden from rep and manager, offered to marketing and the admin.

  • Rewritten pins: bulk-action-dispatch (the flow action seeds the dispatched row into the flow's lead input; no selectedIds param), actions-flows-integrity (both doors' flow inserts real member columns; the campaign param is field-backed and is a flow input; both flows dedupe on their own side's lookup), audit-stamp-readonly (no enrollment write is elevated), campaign-member-lifecycle (campaignId; the control runs the callee as the marketer), script-bodies (the three retired bodies leave the executor; their behaviour pins moved to the new file), and the flow-campaign-enrollment header.

Ablations

Each mutation was applied with objectstack/scripts/ablation-replace.mjs in WRAP mode, so its anchor hit was checked and its restore proven (blob == HEAD, git diff HEAD empty). The boot reads objectstack.config.ts source, so no build sits between a mutation and its reading.

  1. Lead callee back to runAs: 'system': 7 red. Add to Campaign on a lead answered 403 PERMISSION_DENIED for every persona, the #22310 elevated-door refusal. Enroll Members stopped ending refused for rep and manager. The audit pin went red too.
  2. The lead loop's catch counter dropped: 3 red (the partly refused run, and the refused leads runs for rep and manager).
  3. Mark Responded back to an elevated script body: 2 red (rep and manager refusal).
  4. current_user.can(…, "create") dropped from the contact door's visible: 1 red (hidden from sales_rep and sales_manager).

Browser pass (1440 × 900, Chromium /opt/pw-browsers/chromium)

This ran against my own objectstack dev on port 4867, on head 113564f1. Its src/ and test/ are identical to the final head (git diff 113564f1 c8a212b1 -- src test is empty). The org was staffed with demo:staff plus a marketing_user I created. The browser reached the doors through the selection bar, the row ⋮ menu and the campaign record header.

persona lead list: row menu / selection bar contact list: row menu / selection bar campaign header: Enroll Members member header: Mark Responded
admin offered / offered offered / offered offered offered
marketing_user offered / offered offered / offered offered offered
sales_manager hidden / hidden hidden / hidden hidden hidden
sales_rep (na.rep) hidden / hidden hidden / hidden hidden hidden
  • Param dialog, flow-typed Add to Campaign. It renders the field-backed Campaign record picker. From the selection bar there is a confirm step: "This will apply to 2 records. Affected records (2): …". The run then fans out: 2 POST /api/v1/actions/crm_lead/create_campaign, each {"recordId":…,"params":{"campaignId":…}}, each 200 with output.enrolled: true. The result reads "Succeeded 2 / 2". The contact list does the same through add_contact_to_campaign. From the row menu it shows Confirm, then the toast "Leads added to campaign!" / "Contacts added to campaign!". A re-run on an enrolled person answers enrolled: false and shows the same toast.
  • Stored rows. Members were written with created_by = the persona (admin kCSN…, marketing d9oL…) and added_date set.
  • Enroll Members (admin, marketing). The Enrollment Criteria screen comes up, then Submit, then the toast "Eligible members enrolled in campaign.". Members went from 4 to 9 for both.
  • current_user.can in visible evaluates. It is true for admin and marketing and false for manager and rep, on the same pages. No console message mentions can( or current_user.
  • Live server, refused personas, on a fresh campaign. Both Add to Campaign doors answer 400 FLOW_FAILED ("…Node 'create_campaign_member' failed: … You do not have permission…"). Enroll Members, started and resumed over REST, ends status: "refused", with "Enrollment refused for 6 of the people selected: 0 enrolled, 6 refused. …" for the manager and "… 1 of … 0 enrolled, 1 refused. …" for the rep, and the campaign has 0 members. Mark Responded answers 403 PERMISSION_DENIED, and the members stay at Sent.

The ruling's stop conditions were measured and did not trigger. The console runs flow-typed Add to Campaign from the selection bar and from the row menu, and current_user.can in visible evaluates. Nothing contradicts #3914, #22310 or ADR-0066 D4: ablation 1 is #22310 doing its job.

#2073 items 5 and 6 (evidence only)

  • Item 5 still reproduces for the personas who are offered the button. On My Leads (no bulkActions wiring) with no row selected, the header Add to Campaign opens the dialog. The campaign can be picked and Confirm pressed, and the toast then says "This action runs on a single record — select a row first." No request is sent. This held for marketing_user and admin. For sales_rep the header button is no longer drawn.
  • Item 6 no longer reproduces. I sent a campaign id that names no campaign, through POST /api/v1/actions/crm_lead/create_campaign. As the admin it answers 400 FLOW_FAILED: "Campaign: no crm_campaign record has id "T-hc-sales-Campaign B"". As marketing_user it answers 400 FLOW_FAILED: "[Security] Access denied: insert on 'crm_campaign_member' requires edit access to its master record". No orphan member was written in either case. The caller-context insert checks the reference that the elevated body skipped.

Docs and changeset

  • Campaign Members says who can enroll, what Enroll Members ends with, what Add to Campaign answers, and who may press Mark Responded.
  • Profiles says that reps and managers are not offered the doors, and that marketing's row-level rules serve rows a colleague enrolled.
  • Automation says that the two per-person flows run as the caller and are also started by Add to Campaign.
  • Leads and Email & Calendar drop the claim that a rep presses these buttons.
  • API reference replaces its create_campaign modal-body sample with the real mark_primary body, and says why Add to Campaign and Mark Responded are not bodies.
  • Every page above is in all three locales. .changeset/2167-enrollment-enforces-profile.md (minor) covers who can enroll now and what Add to Campaign answers. For API callers it gives FROM → TO: params.crm_campaign → params.campaignId, and { campaignId, count, skipped, ids } → { success, output: { enrolled } }.

Acceptance notes

  • Semver. The two Add to Campaign actions changed their REST param name and their answer, and Mark Responded changed its answer. The changeset says minor and spells out FROM → TO. Raise it to major if those action payloads count as a public API.
  • Now-stale comments outside the file surface, not edited. src/marketing/objects/campaign_member.hook.ts (header of campaign_member_lifecycle) says the mark_responded action "stamps all three fields itself". It now writes status only, and that hook stamps the other two. src/sales/profiles/marketing-user.profile.ts (marketing_campaign_member_updates) says "rows the enrollment flow (system context) created". The rule is still needed, but the rows are now created by the enrolling marketer. The ruling freezes that file. Carrier: none.
  • Internal docs, now stale, outside the file surface. docs/developers/code_examples.md says create_campaign "reads ctx.recordId only". docs/feature-inventory.md (LEA-013, MKT-004, MKT-009) describes the script bodies. docs/architecture/module-split-inventory.json lists create_campaign@crm_lead and add_contact_to_campaign@crm_contact as "action body writes an object in another module". Carrier: none.
  • Pre-existing docs inaccuracy, untouched. The Campaign Members status table says Unsubscribed is set by "A rep on the member record". Neither sales_rep nor sales_manager can edit a member: their sets carry allowEdit: false. Carrier: none.
  • Upstream observation. On the contact list, the selection-bar confirm step names contacts by their job title ("VP of Engineering", "Director of Commercial Systems"), not by name. crm_contact declares nameField: 'full_name'. It is reported to the seat; the full reading is in the dev report.

Deviations

  • The inherited commit e944026c carries (#2167) in its subject. It is landed history on a pushed branch I did not author, so it was not amended and not force-pushed. The card relation is declared here only.
  • The _guarded-iteration.ts surface breach is kept (see above).

Generated by Claude Code

…ember object's allowCreate decides who enrolls (#2167)

The two per-person enrollment flows run as the user and take over the
already-enrolled check; Add to Campaign on the lead and on the contact
become flow actions over them, and their elevated script bodies retire.
Enroll Members counts enrolled and refused people inside the guarded
iteration and ends refused with both counts when anyone was refused.
Mark Responded becomes a declarative update written as the caller.

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
…r pins to the caller-context doors

The two per-person result edges read the callee's output map with both
has() guards, and the refused/clean split is an equality pair that answers
on every variable shape. The retired script bodies leave the script-body
executor; a new suite pins every enrollment door and Mark Responded per
persona, the partly refused run's counts and the clean run's message, and
the visible gate against the server's own effective permissions.

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
…lment door answers

Campaign Members, Profiles, Automation, Leads, Email & Calendar and the
API reference say, in all three languages, that enrolling and recording a
response run as the person who presses the button, so Marketing User and
the administrators enroll and reps and managers are not offered the doors.
The changeset gives the release-notes reader the new answers, and the API
caller the param and answer shapes from and to.

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 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 Oct 20, 2026 2:14am UTC

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Oct 20, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (#2167) · PR #2186 at c8a212b15 · repo:hotcrm seat 2 · os-support-ai · session session_01KqkTQNLcxVc588eZpJmp1r · 2026-10-20T01:41Z · report on hotcrm#2167 (a takeover dev; the first dev died at the account's weekly usage limit after pushing e944026c)

  1. Shape:
  2. The rulings, executed:
    • Q1 letter B (6266676306): every enrollment door writes as the caller. The two per-person flows run runAs: 'user' and own the dedupe. The two Add to Campaign actions are type: 'flow' over them. Mark Responded is a declarative operation: 'update'. src/sales/profiles/*.profile.ts is unchanged, as ruled.
    • Q2 letter a (6267365020):
      • the counters live inside the guarded try and catch;
      • any refusal ends the run refused, with both counts;
      • a clean run ends with the flow's static successMessage.
    • Ablation 1 confirms #22310 doing its job: with the callee back at runAs: 'system', the door is refused for every persona.
  3. Inherited commit and deviations, the seat's calls:
    • _guarded-iteration.ts (outside the claim's surface): kept. Q2 letter a places the counters in the try and catch that this helper builds. The new third argument is optional, and the two-argument output is measured byte-identical.
    • Stale comments and internal docs outside the surface: not edited, correctly. The dev stopped at the surface. The seat folds them into hotcrm#2060 item 16 (see 6).
    • The (#2167) in e944026c's subject is left alone: no history rewrite on a pushed branch. The squash merge replaces the subject anyway.
  4. Changeset and docs, read sentence by sentence:
    • Who can enroll, Mark Responded, what Enroll Members says, what Add to Campaign answers: each sentence matches a measured door: the 29-case suite, the browser table, or the live REST run. The quoted messages are the strings the tests assert.
    • "A campaign id that names no campaign is now refused": measured. 400 FLOW_FAILED for both the admin and marketing, with 0 orphan rows.
    • "The old key is refused with 400 as an unknown param": not measured on this head. It is the platform's strict params gate: packages/spec/src/contracts/ai-service.ts:224 names "unknown action param — a 400", with code unknown_field in errors.zod.ts:242. This changelog has recorded that gate refusing an undeclared param before (3.0.0, params.selectedIds). The seat accepts it on that contract.
    • The answer shapes for API callers: these match the flow result and the declarative update that the new tests read.
    • Docs:
      • Campaign Members: who can enroll, the two run endings, the toolbar and row-menu answers, and who may press Mark Responded.
      • Profiles: the manager and rep bullets, and marketing's row-level rule.
      • Automation: the two subflow rows.
      • Leads and Email & Calendar: one clause each.
      • API reference: the mark_primary sample is the shipped body with its id guard and timeoutMs left out, so it is an illustration, not a copy. Its added paragraph matches the diff.
      • The zh-Hans and zh-Hant pages carry the same hunks, line for line.
  5. Gates:
    • The takeover dev's verify on c8a212b1: every build-half step exits 0. The three shards (67, 66 and 66 files, 1104, 1258 + 1 skipped and 1594 passed) each give command-exit 0.
    • The refusal cases assert code and status: 403 PERMISSION_DENIED and 400 FLOW_FAILED.
    • Ablations 1–4 go red as predicted, and each restore is proven blob-equal.
    • The ruled browser pass ran at 1440 per persona, on 113564f1. Its src/ and test/ equal the head's. Neither stop condition triggered.
    • CI on c8a212b15 at this review: 9 of 10 checks success; Build and Test (22.x) is running. Landing waits for every check.
  6. Findings, dispositions:

Landing: PR #2185 lands first (both PRs edit content/docs/administration/automation*.mdx, in different rows). Then this seat brings main in through the relay. Once every check on that head is green, it flips the PR ready and arms auto-merge. The merge closes #2167.


Generated by Claude Code

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

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants