Repository navigation
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 intoOct 20, 2026
Conversation
…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>
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
Author
席位核对ACCEPT (#2167) · PR #2186 at
Landing: PR #2185 lands first (both PRs edit Generated by Claude Code |
This was referenced Oct 20, 2026
This was referenced Oct 20, 2026
This was referenced Oct 20, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ownallowCreatedecides who enrolls. This executes the card's rulings: Q1 letter B (6266676306) and Q2 letter a (6267365020), on top of the letter-A outcome (6257118101items 1, 3 and 4). No platform card, andsrc/sales/profiles/*.profile.tsis 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, mergesorigin/main(b6cadbd5), and adds the fixes, tests, docs and changeset it lacked.What changed
campaign_enrollment)runAs: 'system'sub-flows; a refused member was swallowed by the guarded iteration and the run reported success, with no result messagerunAs: 'user'and own the already-enrolled check; the guarded try and catch count enrolled and refused people; any refusal ends the runrefusedwith both counts, and a clean run endscompletedwith the staticsuccessMessagecreate_campaign) and a contact (add_contact_to_campaign)type: 'flow'actions over the side's one enroll-one-person flow (recordIdParam= the flow's person input, paramcampaignId)mark_responded)operation: 'update'+patch: { status: 'responded' }, written as the caller;campaign_member_lifecyclestampshas_responded/response_datevisiblecurrent_user.can("crm_campaign_member", "create"); Mark Responded addscurrent_user.can("crm_campaign_member", "edit")The inherited commit, hunk by hunk
runAs: 'user'with the dedupe moved into them and anenrolledoutput (campaign-member-enroll.flow.ts); the subflow output, counters,check_refusedgateway,end_refusednode and flowsuccessMessage(campaign-enrollment.flow.ts); the two flow actions, theircampaignIdparams andvisibleterms (lead.actions.ts,contact.actions.ts); Enroll Members'visibleand theoperation: '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).b2/c2read the callee's output map with bothhas(vars.…)guards, and the refused/clean paire10/e11is the equality pair!= 0/== 0. On the inherited head,test/flow-variable-conditions.test.tswas red on those three edges:{"leadEnrolment":null}: No such key: enrolled, and{"refusedCount":null}: no such overload, a null ordered against an int.test/campaign-enrollment-doors.test.ts.src/sales/flows/_guarded-iteration.tsgains an optional third argument, the catch handler'sassignments. 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.stringifyof the old and the newguarded('k', region)are equal).Tests
All of the following ran on
c8a212b1, the final head, through the shared verify lock.pnpm validateexit 0 (✓ Validation passed) ·pnpm typecheckexit 0 ·pnpm lintexit 0 (16 suggestions, unchanged) ·pnpm lint:i18n-gateexit 0 (0 i18n/missing-* issues) ·pnpm hygieneexit 0 ·pnpm hygiene:tokensexit 0 (src/marketing authored total ~9,730 tokens, ceiling ~15,000) ·pnpm buildexit 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 wascommand-exit 0.The inherited head (
9b7ae935, the merge before this PR's fixes) failed 17 tests: 6 inscript-bodies, 3 inactions-flows-integrity, 1 inaudit-stamp-readonly, 1 inbulk-action-dispatch, 2 incampaign-member-lifecycleand 4 inflow-variable-conditions.New,
test/campaign-enrollment-doors.test.ts(29 tests). Forsales_repandsales_manager:PERMISSION_DENIED, the control);FLOW_FAILED, the failed node being the insert), and no member is written;refusedwith0 enrolled, and the campaign has no member;PERMISSION_DENIED), and the member stays at Sent.For
marketing_userand the admin:created_by= the caller, and a second run answersenrolled: falseand writes nothing;successMessageis the flow's static one;A partly refused run stages one refused insert through
recordEngineWrites. It endsrefused, the message equalsEnrollment 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'svisibleis evaluated with@objectstack/formulaagainst the server's ownGET /api/v1/auth/me/permissionsper 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; noselectedIdsparam),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 theflow-campaign-enrollmentheader.Ablations
Each mutation was applied with
objectstack/scripts/ablation-replace.mjsin WRAP mode, so its anchor hit was checked and its restore proven (blob == HEAD,git diff HEADempty). The boot readsobjectstack.config.tssource, so no build sits between a mutation and its reading.runAs: 'system': 7 red. Add to Campaign on a lead answered 403PERMISSION_DENIEDfor every persona, the #22310 elevated-door refusal. Enroll Members stopped endingrefusedfor rep and manager. The audit pin went red too.current_user.can(…, "create")dropped from the contact door'svisible: 1 red (hidden from sales_rep and sales_manager).Browser pass (1440 × 900, Chromium
/opt/pw-browsers/chromium)This ran against my own
objectstack devon port 4867, on head113564f1. Itssrc/andtest/are identical to the final head (git diff 113564f1 c8a212b1 -- src testis empty). The org was staffed withdemo:staffplus amarketing_userI created. The browser reached the doors through the selection bar, the row ⋮ menu and the campaign record header.na.rep)POST /api/v1/actions/crm_lead/create_campaign, each{"recordId":…,"params":{"campaignId":…}}, each 200 withoutput.enrolled: true. The result reads "Succeeded 2 / 2". The contact list does the same throughadd_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 answersenrolled: falseand shows the same toast.created_by= the persona (adminkCSN…, marketingd9oL…) andadded_dateset.current_user.caninvisibleevaluates. It is true for admin and marketing and false for manager and rep, on the same pages. No console message mentionscan(orcurrent_user.FLOW_FAILED("…Node 'create_campaign_member' failed: … You do not have permission…"). Enroll Members, started and resumed over REST, endsstatus: "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 403PERMISSION_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.caninvisibleevaluates. Nothing contradicts #3914, #22310 or ADR-0066 D4: ablation 1 is #22310 doing its job.#2073 items 5 and 6 (evidence only)
bulkActionswiring) 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. Forsales_repthe header button is no longer drawn.POST /api/v1/actions/crm_lead/create_campaign. As the admin it answers 400FLOW_FAILED: "Campaign: no crm_campaign record has id "T-hc-sales-Campaign B"". As marketing_user it answers 400FLOW_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
create_campaignmodal-body sample with the realmark_primarybody, and says why Add to Campaign and Mark Responded are not bodies..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
minorand spells out FROM → TO. Raise it tomajorif those action payloads count as a public API.src/marketing/objects/campaign_member.hook.ts(header ofcampaign_member_lifecycle) says themark_respondedaction "stamps all three fields itself". It now writesstatusonly, 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.docs/developers/code_examples.mdsayscreate_campaign"readsctx.recordIdonly".docs/feature-inventory.md(LEA-013, MKT-004, MKT-009) describes the script bodies.docs/architecture/module-split-inventory.jsonlistscreate_campaign@crm_leadandadd_contact_to_campaign@crm_contactas "action body writes an object in another module". Carrier: none.sales_repnorsales_managercan edit a member: their sets carryallowEdit: false. Carrier: none.crm_contactdeclaresnameField: 'full_name'. It is reported to the seat; the full reading is in the dev report.Deviations
e944026ccarries(#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._guarded-iteration.tssurface breach is kept (see above).Generated by Claude Code