From 821ed0aaee5e2616d0cde7f9cae0f30c6305a905 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 24 Sep 2026 22:24:20 -0700 Subject: [PATCH 01/30] docs: M3 B2B org import plan and decision log Single place for the plan to get EasyCLA companies into Salesforce for the M3 Organization lens, replacing the Path A/B analysis in lfx-self-serve#2750: there is one Salesforce org, the "old platform org" is the Organization Service Postgres, and the sfid_b2b identity is a DB trigger, so an ID-preserving import is impossible. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/easycla-ss-migration/m3-b2b-org-import.md diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md new file mode 100644 index 000000000..62b867646 --- /dev/null +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -0,0 +1,100 @@ + + +# M3: getting EasyCLA companies into Salesforce + +**Status**: draft 2026-09-24 · **Owner**: Michal (engineering) +**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate SFIDs) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) +**Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) + +This file is the single place for the plan and its decisions. Tickets carry pointers only. Append to the decision log (§7); do not rewrite history. + +## 1. One Salesforce org, and where the IDs come from + +What earlier notes called the "old platform org" is the **Organization Service's Postgres table**, not a second Salesforce. + +| Store | What it is | Key | Written by | +| --- | --- | --- | --- | +| Salesforce (the CRM, "B2B") | The only Salesforce org | `001…` Id, assigned by Salesforce | Sales ops, Apex `JoinNowForm` | +| `salesforce_b2b."Account"` (platform Postgres) | Replica of the CRM, Stitch sync every 30 min | `Id` | Stitch | +| `salesforce.account` (platform Postgres, the **Org Service** table) | Heroku Connect copy of Salesforce until 2023-04-04; since then written by the Org Service only | `sfid`: `001…` copied from the CRM, or `lf…` generated by the Org Service | Org Service (`lf…`), a DB trigger (`001…`) | +| EasyCLA `cla-prod-companies` | CCLA company | `company_id`; `company_external_id` holds an Org Service `sfid` | EasyCLA v3/v4 | + +Evidence: + +- Org Service stopped writing Salesforce on 2023-04-04 and mints its own IDs: [`organization/repository.go`](https://github.com/linuxfoundation/organization-service/blob/main/organization/repository.go) (`CreateOrganization`: "we are not using the SFDC integration to create or update account records", `platformSFID := common.GenerateID()`). `lf…` IDs come from [`lfx-kit/common/id.go`](https://github.com/linuxfoundation/lfx-kit/blob/main/common/id.go). +- The CRM → Org Service copy is a Postgres trigger, `salesforce.function_create_update_b2c_account` ([2023-08-10](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230810205345_salesforce-account-trigger-create.sql), [2023-09-05](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230905220112_account_b2b_procedure_updates.sql)). On insert or update of a replica row it looks for `salesforce.account WHERE sfid = new."Id"` only; no match → inserts a row with `sfid = new."Id"` and `sfid_b2b = new."Id"`. Live in prod (Eric, TECHNICAL.md §7.3, 2026-09-15). +- The domain-based linker `update_account_sfid_b2b_value()` is not scheduled, so `lf…` rows never get `sfid_b2b` (Eric, TECHNICAL.md §1). + +What follows: + +1. `sfid_b2b == sfid` for every CRM account because the trigger copies the Id. Latency CRM → Org Service ≈ 0–40 min (Stitch interval + trigger). +2. **Importing into Salesforce while keeping an existing ID is impossible.** Salesforce assigns Ids; nothing in LFX ever pushed one in. "Path A" in #2750 is dropped. +3. **Every company we create or match an account for gets a new `company_external_id`.** Only companies whose ID already resolves to a live CRM account stay untouched. +4. Creating an account for an `lf…` company leaves the `lf…` row orphaned next to the new `001…` shadow row. Harmless: EasyCLA and the Corporate Console follow `company_external_id` to the shadow row. The Org Service is being retired. + +## 2. Populations (prod Snowflake mirrors, 2026-09-18/19; details in #2749) + +| Active-CCLA companies | Count | Route | +| --- | ---: | --- | +| `001…` resolving to a live CRM account | 995 | Nothing to do | +| `001…` in the Org Service, gone from the CRM | 962 | Domain-match a live account (374) else create; rewrite | +| `lf…` | 331 | Domain-match (57) else create (271); 3 without website → manual; rewrite | +| Resolves nowhere | 18 | Manual | + +Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 'company'`, signed and approved), evaluated at run time. ID shape decides the route, not eligibility. Companies without an active CCLA stay in EasyCLA untouched; their disposition before Console retirement is an exit criterion of #2750. Growth: ~16 new CCLA companies per month. + +## 3. Plan + +0. **Agree the Salesforce side with sales ops** (§4). No code before this. +1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies; the 5 manual duplicate-SFID merges from #2056. +2. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), per company as in §5. Tranches 10 → 100 → rest. +3. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. +4. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. +5. **M3 GA.** +6. **Daily sweep** = the same tool on a schedule, for newly signed CCLAs. It creates the CRM account only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). v4 `CreateOrg` keeps creating Org Service orgs until §6. +7. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. + +Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assigns ACS org scopes right after creating the org, and a Salesforce-created account is invisible to the Org Service, ACS and the Console for up to ~40 min. Doing it properly means async role assignment or moving CLA-manager authorization off ACS, which is Eric's Member Service work (§6). + +## 4. Salesforce side (to agree with sales ops) + +- **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website` / `Domain_Alias__c`). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. +- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate }` → `{ id, action: "matched" | "created" }`. Idempotent on `externalKey` via a new External ID field on `Account` (e.g. `LFX_Org_Id__c`). No usable External ID field exists today. +- **Field values**: `RecordTypeId = 01241000000bkf1AAA` (the record type the Org Service serves), `Type`, `IsMember__c = false`, origin marker, `OwnerId` (a queue). +- **Signing entities**: one EasyCLA company ↔ one `Account`; `signing_entity_name` stays in EasyCLA. No child accounts in M3. +- **Bulk**: the tool calls the endpoint per company (~1,300 calls in tranches). One dedupe path for bulk and sweep. + +## 5. Per-company sequence (the ingest tool) + +1. Skip unless the predicate holds and `company_external_id` does not resolve to a live CRM account. +2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. +3. Call the Apex endpoint → `newId`. +4. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. +5. Set `company_external_id = newId`; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). +6. Move ACS scopes for each CLA manager to `newId`. EasyCLA authorizes org-lens calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so this is what makes the lens and the Console work. +7. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). +8. Log `company_id, old_id, new_id, action, timestamp`. + +No second SFID column in EasyCLA: nothing needs the old ID after step 6, and it would not avoid the ACS move. + +## 6. Open questions and post-M3 + +| # | Question | Owner | +| --- | --- | --- | +| 1 | Approve the Apex endpoint, the External ID field and the field values (§4) | Mindy / Midhun | +| 2 | Sandbox access and review cadence for the Apex contribution | Midhun | +| 3 | Confirm v4 creation moves to the Member Service post-M3; M3 relies on the sweep | Eric | +| 4 | Does ACS need the org in the Org Service when an org scope is created? (test on dev) | us | +| 5 | Remove the Member-only filter from the member-service B2B backfill | Eric | + +Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service; #2751 read paths made non-mutating. + +## 7. Decision log + +- **2026-09-10** Architecture call (Eric, Michal, David Deal): CRM supports hierarchy; companies without a CCLA excluded; Eric prefers CCLAs keyed on the Salesforce account via the Member Service (post-M3); Eric "No" to importing with the same ID, then "let me look into that"; Salesforce team wants a new Apex class in `LF_SF_CICD_Setup`. +- **2026-09-17** Salesforce team: no objection in principle, confirming internally; Apex interface expected; AI-written contribution accepted. +- **2026-09-18** `sfid_b2b` identity and one-way direction measured from Snowflake; #2750 written around "Path A" (ID-preserving import). +- **2026-09-19** Ingest predicate set to active CCLA + real `001…`; `lf…` companies excluded, 331 flagged for a separate disposition (#2749). +- **2026-09-23** Lukasz asks on #2750 whether preconditions are confirmed. +- **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. From fa32e72b18777b6691ed98122a34bc0ca29720db Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 25 Sep 2026 14:24:30 -0700 Subject: [PATCH 02/30] fix(review): address PR #5223 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot-pull-request-reviewer and coderabbitai: - m3-b2b-org-import.md §5: dry run per tranche with human approval of every domain match; rejected and ambiguous matches go to manual - §4: endpoint returns "ambiguous" when several Accounts share the domain; External ID field is unique, keyed on the old company_external_id and set on create only; record type resolved by DeveloperName - §4/§5: one call per distinct company_external_id, rewriting every EasyCLA row that shares it (signing entities) - §5: copy every ACS scope carrying the old ID to the new ID before the rewrite, delete the old scopes after; resume unfinished orgs on rerun - §7: decision log entry Resolves 8 review threads. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 62b867646..ae0ae608e 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -48,7 +48,7 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 0. **Agree the Salesforce side with sales ops** (§4). No code before this. 1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies; the 5 manual duplicate-SFID merges from #2056. -2. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), per company as in §5. Tranches 10 → 100 → rest. +2. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. 3. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. 4. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. 5. **M3 GA.** @@ -60,23 +60,27 @@ Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assign ## 4. Salesforce side (to agree with sales ops) - **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website` / `Domain_Alias__c`). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. -- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate }` → `{ id, action: "matched" | "created" }`. Idempotent on `externalKey` via a new External ID field on `Account` (e.g. `LFX_Org_Id__c`). No usable External ID field exists today. -- **Field values**: `RecordTypeId = 01241000000bkf1AAA` (the record type the Org Service serves), `Type`, `IsMember__c = false`, origin marker, `OwnerId` (a queue). -- **Signing entities**: one EasyCLA company ↔ one `Account`; `signing_entity_name` stays in EasyCLA. No child accounts in M3. -- **Bulk**: the tool calls the endpoint per company (~1,300 calls in tranches). One dedupe path for bulk and sweep. +- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`), set on create only; a repeat call for a matched Account matches it again by domain. No usable External ID field exists today. +- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, origin marker, `OwnerId` (a queue). +- **Signing entities**: one `company_external_id` ↔ one `Account`. The EasyCLA rows that share it (one per signing entity) are rewritten together; `signing_entity_name` stays in EasyCLA. No child accounts in M3. +- **Bulk**: the tool calls the endpoint once per distinct `company_external_id` (at most ~1,300 calls, in tranches). One dedupe path for bulk and sweep. ## 5. Per-company sequence (the ingest tool) -1. Skip unless the predicate holds and `company_external_id` does not resolve to a live CRM account. +The tool runs once per distinct `company_external_id`. Several EasyCLA rows share one when an org has several signing entities; each step applies to all of them. + +1. Skip if the predicate fails, or the ID resolves to a live CRM account and the tool never touched it (`previous_company_external_id` empty), or the log marks it complete. Otherwise resume at the first incomplete step; every step is safe to repeat. 2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. -3. Call the Apex endpoint → `newId`. -4. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. -5. Set `company_external_id = newId`; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). -6. Move ACS scopes for each CLA manager to `newId`. EasyCLA authorizes org-lens calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so this is what makes the lens and the Console work. -7. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). -8. Log `company_id, old_id, new_id, action, timestamp`. +3. Dry-run the Apex endpoint for the tranche. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company); rejected and `ambiguous` go to manual. +4. Call the Apex endpoint → `newId`. +5. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. +6. Copy every ACS scope that carries the old ID (any role; org and project|org scopes) to `newId`. EasyCLA authorizes lens and Console calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so the new scopes must exist before step 7. +7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). +8. Delete the scopes on the old ID. +9. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). +10. Log each completed step: `old_id, new_id, action, step, timestamp`. -No second SFID column in EasyCLA: nothing needs the old ID after step 6, and it would not avoid the ACS move. +No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. ## 6. Open questions and post-M3 @@ -98,3 +102,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-19** Ingest predicate set to active CCLA + real `001…`; `lf…` companies excluded, 331 flagged for a separate disposition (#2749). - **2026-09-23** Lukasz asks on #2750 whether preconditions are confirmed. - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. +- **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs. From 21ab38335a7aa6aeca9cfae2dcc135c4a0997a7f Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 25 Sep 2026 15:56:07 -0700 Subject: [PATCH 03/30] fix(review): address second round of PR #5223 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from coderabbitai and copilot-pull-request-reviewer[bot]: - m3-b2b-org-import.md §5 step 4: the write call must return the approved dry-run result, else the org goes to manual (per coderabbitai and Copilot) - m3-b2b-org-import.md §5 step 1, §2: already-live accounts get member-service registration (step 9); its backfill covers Membership accounts only (per Copilot) - m3-b2b-org-import.md §7: decision-log entry updated Resolves 2 review threads and 1 review-level comment. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index ae0ae608e..96c752dae 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -37,7 +37,7 @@ What follows: | Active-CCLA companies | Count | Route | | --- | ---: | --- | -| `001…` resolving to a live CRM account | 995 | Nothing to do | +| `001…` resolving to a live CRM account | 995 | Register in member-service only (§5 step 1) | | `001…` in the Org Service, gone from the CRM | 962 | Domain-match a live account (374) else create; rewrite | | `lf…` | 331 | Domain-match (57) else create (271); 3 without website → manual; rewrite | | Resolves nowhere | 18 | Manual | @@ -69,10 +69,10 @@ Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assign The tool runs once per distinct `company_external_id`. Several EasyCLA rows share one when an org has several signing entities; each step applies to all of them. -1. Skip if the predicate fails, or the ID resolves to a live CRM account and the tool never touched it (`previous_company_external_id` empty), or the log marks it complete. Otherwise resume at the first incomplete step; every step is safe to repeat. +1. Skip if the predicate fails or the log marks it complete. If the ID resolves to a live CRM account and the tool never touched it (`previous_company_external_id` empty), do step 9 only: the member-service backfill covers Membership accounts only. Otherwise resume at the first incomplete step; every step is safe to repeat. 2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. 3. Dry-run the Apex endpoint for the tranche. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company); rejected and `ambiguous` go to manual. -4. Call the Apex endpoint → `newId`. +4. Call the Apex endpoint → `newId`. If the action, or the ID of a `matched` Account, differs from the approved dry run, stop; the org goes to manual. 5. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. 6. Copy every ACS scope that carries the old ID (any role; org and project|org scopes) to `newId`. EasyCLA authorizes lens and Console calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so the new scopes must exist before step 7. 7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). @@ -102,4 +102,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-19** Ingest predicate set to active CCLA + real `001…`; `lf…` companies excluded, 331 flagged for a separate disposition (#2749). - **2026-09-23** Lukasz asks on #2750 whether preconditions are confirmed. - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. -- **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs. +- **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. From 9b1703e584593b5ae82beb96887d2ace3a7be05f Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 03:32:17 -0700 Subject: [PATCH 04/30] docs(m3): duplicate review before import; merges move post-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured duplicate shapes in the prod mirror: 78 active-CCLA rows share a website domain or normalized name under different IDs, and 17 carry a shared domain (github.com, nowebsite.com) that a domain match would collapse onto the wrong Account. - Add a duplicate-review step (§3) whose approved collapses feed the dry run - Shared domains never match; those companies go to manual (§4, §5) - Dry run reported grouped by target Account; collapses approved (§5) - One Account may carry several company_external_ids (§4) - #2056 row merges move after the import; drop them from pre-import cleanup - Rewrite event_company_sfid and company_sfid_* keys with the ID (events are queried by SFID) - Decision log entry 2026-09-29 Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 96c752dae..e641bcd27 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -4,7 +4,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3: getting EasyCLA companies into Salesforce **Status**: draft 2026-09-24 · **Owner**: Michal (engineering) -**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate SFIDs) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) +**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) **Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) This file is the single place for the plan and its decisions. Tickets carry pointers only. Append to the decision log (§7); do not rewrite history. @@ -47,22 +47,23 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = ## 3. Plan 0. **Agree the Salesforce side with sales ops** (§4). No code before this. -1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies; the 5 manual duplicate-SFID merges from #2056. -2. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. -3. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. -4. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. -5. **M3 GA.** -6. **Daily sweep** = the same tool on a schedule, for newly signed CCLAs. It creates the CRM account only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). v4 `CreateOrg` keeps creating Org Service orgs until §6. -7. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. +1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies. +2. **Duplicate review**: a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. +3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. +4. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. +5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. +6. **M3 GA.** +7. **Daily sweep** = the same tool on a schedule, for newly signed CCLAs. It creates the CRM account only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). v4 `CreateOrg` keeps creating Org Service orgs until §6. +8. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assigns ACS org scopes right after creating the org, and a Salesforce-created account is invisible to the Org Service, ACS and the Console for up to ~40 min. Doing it properly means async role assignment or moving CLA-manager authorization off ACS, which is Eric's Member Service work (§6). ## 4. Salesforce side (to agree with sales ops) - **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website` / `Domain_Alias__c`). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. -- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`), set on create only; a repeat call for a matched Account matches it again by domain. No usable External ID field exists today. +- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Domains on a shared-domain list (`github.com`, `nowebsite.com`, mail providers) never match; the tool sends those companies to manual instead (17 today). Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`), set on create only; a repeat call for a matched Account matches it again by domain. No usable External ID field exists today. - **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, origin marker, `OwnerId` (a queue). -- **Signing entities**: one `company_external_id` ↔ one `Account`. The EasyCLA rows that share it (one per signing entity) are rewritten together; `signing_entity_name` stays in EasyCLA. No child accounts in M3. +- **Signing entities**: one `Account` ↔ one or more `company_external_id`s. Rows that share an ID are rewritten together; IDs approved as a collapse (§3 step 2) land on the same Account. All of them become the Account's signing entities; `signing_entity_name` stays in EasyCLA. EasyCLA already keeps several rows behind one SFID; surfacing all of them is #2056's resolution logic. No child accounts in M3. - **Bulk**: the tool calls the endpoint once per distinct `company_external_id` (at most ~1,300 calls, in tranches). One dedupe path for bulk and sweep. ## 5. Per-company sequence (the ingest tool) @@ -70,12 +71,12 @@ Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assign The tool runs once per distinct `company_external_id`. Several EasyCLA rows share one when an org has several signing entities; each step applies to all of them. 1. Skip if the predicate fails or the log marks it complete. If the ID resolves to a live CRM account and the tool never touched it (`previous_company_external_id` empty), do step 9 only: the member-service backfill covers Membership accounts only. Otherwise resume at the first incomplete step; every step is safe to repeat. -2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. -3. Dry-run the Apex endpoint for the tranche. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company); rejected and `ambiguous` go to manual. +2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. No website, or a shared domain (§4) → manual. +3. Dry-run the Apex endpoint for the tranche and report it grouped by target Account. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company) and every collapse — several IDs landing on one Account — against the duplicate review (§3 step 2); rejected and `ambiguous` go to manual. 4. Call the Apex endpoint → `newId`. If the action, or the ID of a `matched` Account, differs from the approved dry run, stop; the org goes to manual. 5. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. 6. Copy every ACS scope that carries the old ID (any role; org and project|org scopes) to `newId`. EasyCLA authorizes lens and Console calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so the new scopes must exist before step 7. -7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). +7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). Rewrite the company's events as well — `event_company_sfid` and the `company_sfid_*` composite GSI keys: the Console activity log and `GET /v4/company/{id}/project/{sfid}/events` query events by SFID ([`events/repository.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/events/repository.go), `GetCompanyClaGroupEvents`), so pre-import history would disappear otherwise. 8. Delete the scopes on the old ID. 9. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). 10. Log each completed step: `old_id, new_id, action, step, timestamp`. @@ -103,3 +104,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-23** Lukasz asks on #2750 whether preconditions are confirmed. - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. +- **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. From 8623543d086353064fe44e7778cf964a53c731a7 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 03:43:56 -0700 Subject: [PATCH 05/30] docs(m3): link the duplicate-review task (lfx-self-serve#3085) Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index e641bcd27..f577957ad 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -4,7 +4,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3: getting EasyCLA companies into Salesforce **Status**: draft 2026-09-24 · **Owner**: Michal (engineering) -**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) +**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085) (duplicate review) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) **Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) This file is the single place for the plan and its decisions. Tickets carry pointers only. Append to the decision log (§7); do not rewrite history. @@ -48,7 +48,7 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 0. **Agree the Salesforce side with sales ops** (§4). No code before this. 1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies. -2. **Duplicate review**: a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. +2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. 4. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. 5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. From 1fe977212ad058bc28eca7bcd5f5ade2d9065d4b Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 04:19:27 -0700 Subject: [PATCH 06/30] docs(m3): route active-CCLA companies without a usable external ID (lfx-self-serve#2054) 43 active-CCLA companies carry an empty or malformed company_external_id and had no row in the population table. They go to the manual set with the 18 unresolvable ones; the ingest tool never creates Org Service orgs. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index f577957ad..e0ee5a520 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -4,7 +4,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3: getting EasyCLA companies into Salesforce **Status**: draft 2026-09-24 · **Owner**: Michal (engineering) -**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085) (duplicate review) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) +**Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) (unreachable companies) · [#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085) (duplicate review) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) **Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) This file is the single place for the plan and its decisions. Tickets carry pointers only. Append to the decision log (§7); do not rewrite history. @@ -41,13 +41,14 @@ What follows: | `001…` in the Org Service, gone from the CRM | 962 | Domain-match a live account (374) else create; rewrite | | `lf…` | 331 | Domain-match (57) else create (271); 3 without website → manual; rewrite | | Resolves nowhere | 18 | Manual | +| No usable `company_external_id` (empty or malformed; 2026-09-29) | 43 | Manual: no Org Service row, so no website. 5 have an exact CRM name match; 28 have no ECLA. Listed in [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) | Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 'company'`, signed and approved), evaluated at run time. ID shape decides the route, not eligibility. Companies without an active CCLA stay in EasyCLA untouched; their disposition before Console retirement is an exit criterion of #2750. Growth: ~16 new CCLA companies per month. ## 3. Plan 0. **Agree the Salesforce side with sales ops** (§4). No code before this. -1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749)): the 18 unresolvable companies. +1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749), [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054)): the 18 unresolvable companies and the 43 without a usable ID. Both sets are handled by hand; the ingest tool never creates Org Service orgs (`CreateOrg`), only Salesforce Accounts through §4. 2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. 4. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. @@ -104,4 +105,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-23** Lukasz asks on #2750 whether preconditions are confirmed. - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. -- **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. +- **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. From 52078ee5bc9b0c922a32cf4a1b16901d2652ad1d Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 16:08:19 -0700 Subject: [PATCH 07/30] docs(m3): move #2751 pure reads into M3; log the PR #5227 review The Organization lens already reads GET /v4/company/external/{id}/cla-groups, which creates a company row on read, so the non-mutating read paths (lfx-self-serve#2751) must ship before the lens CLA tab, not post-M3. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index e0ee5a520..92bf12499 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -52,7 +52,7 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. 4. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. -5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. +5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) in its own PR, dev-tested on the Corporate Console with an org that has no row, before the lens CLA tab reaches prod. 6. **M3 GA.** 7. **Daily sweep** = the same tool on a schedule, for newly signed CCLAs. It creates the CRM account only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). v4 `CreateOrg` keeps creating Org Service orgs until §6. 8. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. @@ -94,7 +94,7 @@ No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it | 4 | Does ACS need the org in the Org Service when an org scope is created? (test on dev) | us | | 5 | Remove the Member-only filter from the member-service B2B backfill | Eric | -Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service; #2751 read paths made non-mutating. +Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. ## 7. Decision log @@ -106,3 +106,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. - **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. +- **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships in its own PR before the lens CLA tab; the shared `path-companyID` widening flagged on the PR. From be1f2888f021ec51e4fdeb694dbb0ef43c1f0f31 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 18:56:20 -0700 Subject: [PATCH 08/30] docs(m3): liveness via member-service, sweep registers only Lukasz's tool checks liveness through member-service GET /b2b_orgs/{id}, which reads Salesforce; the Org Service also serves IDs the CRM lost, so it is not a liveness check. The sweep registers live IDs and lists the rest for the sales-ops mapping CSV until the Apex endpoint is agreed. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 92bf12499..b9dfaa4af 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -51,10 +51,10 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749), [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054)): the 18 unresolvable companies and the 43 without a usable ID. Both sets are handled by hand; the ingest tool never creates Org Service orgs (`CreateOrg`), only Salesforce Accounts through §4. 2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. -4. **Verify per tranche**: CRM account exists; Org Service serves it (`GET /orgs/{id}`); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. +4. **Verify per tranche**: the CRM Account exists (member-service `GET /b2b_orgs/{id}`, which reads Salesforce; the Org Service also serves IDs the CRM no longer has, §2, so it is not a liveness check); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. 5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) in its own PR, dev-tested on the Corporate Console with an org that has no row, before the lens CLA tab reaches prod. 6. **M3 GA.** -7. **Daily sweep** = the same tool on a schedule, for newly signed CCLAs. It creates the CRM account only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). v4 `CreateOrg` keeps creating Org Service orgs until §6. +7. **Daily sweep** = the same tool on a schedule (GitHub Actions, `register` route only), for newly signed CCLAs: companies whose ID is a live CRM Account are registered with member-service; the rest (new `lf…` orgs, since v4 `CreateOrg` keeps creating Org Service orgs until §6) are listed for the sales-ops mapping CSV, or created through Apex once §4 is agreed. Either way an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). 8. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assigns ACS org scopes right after creating the org, and a Salesforce-created account is invisible to the Org Service, ACS and the Console for up to ~40 min. Doing it properly means async role assignment or moving CLA-manager authorization off ACS, which is Eric's Member Service work (§6). From b9e42bfb92dc7c4e7b7f7613ec5dc5dcd40ddeb2 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 21:42:14 -0700 Subject: [PATCH 09/30] docs(m3): #2751 stays in the tool PR, tested on dev after merge Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index b9dfaa4af..866a11ba3 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -52,7 +52,7 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. 4. **Verify per tranche**: the CRM Account exists (member-service `GET /b2b_orgs/{id}`, which reads Salesforce; the Org Service also serves IDs the CRM no longer has, §2, so it is not a liveness check); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. -5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) in its own PR, dev-tested on the Corporate Console with an org that has no row, before the lens CLA tab reaches prod. +5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) with the tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)), tested on dev with an org that has no row, before the lens CLA tab reaches prod. 6. **M3 GA.** 7. **Daily sweep** = the same tool on a schedule (GitHub Actions, `register` route only), for newly signed CCLAs: companies whose ID is a live CRM Account are registered with member-service; the rest (new `lf…` orgs, since v4 `CreateOrg` keeps creating Org Service orgs until §6) are listed for the sales-ops mapping CSV, or created through Apex once §4 is agreed. Either way an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). 8. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. @@ -106,4 +106,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. - **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. -- **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships in its own PR before the lens CLA tab; the shared `path-companyID` widening flagged on the PR. +- **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships before the lens CLA tab (Lukasz keeps it in #5227 for time, tested on dev after merge); the shared `path-companyID` widening flagged on the PR. From 922d5649b0968101a2b7612ae5cbc4bb7507b10c Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 29 Sep 2026 22:19:41 -0700 Subject: [PATCH 10/30] docs(m3): origin marker is AccountSource = EasyCLA, record type named Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 866a11ba3..0d2d17d6e 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -63,7 +63,7 @@ Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assign - **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website` / `Domain_Alias__c`). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. - **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Domains on a shared-domain list (`github.com`, `nowebsite.com`, mail providers) never match; the tool sends those companies to manual instead (17 today). Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`), set on create only; a repeat call for a matched Account matches it again by domain. No usable External ID field exists today. -- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, origin marker, `OwnerId` (a queue). +- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` = `Company_Account` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, `AccountSource = "EasyCLA"` as the origin marker (Mindy's ask on the 2026-09-29 call; a new picklist value, Midhun to confirm the field), `OwnerId` (a queue). - **Signing entities**: one `Account` ↔ one or more `company_external_id`s. Rows that share an ID are rewritten together; IDs approved as a collapse (§3 step 2) land on the same Account. All of them become the Account's signing entities; `signing_entity_name` stays in EasyCLA. EasyCLA already keeps several rows behind one SFID; surfacing all of them is #2056's resolution logic. No child accounts in M3. - **Bulk**: the tool calls the endpoint once per distinct `company_external_id` (at most ~1,300 calls, in tranches). One dedupe path for bulk and sweep. From ae75f8e3a00c4cee62bb68f8a40f30cb14daa2eb Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Wed, 30 Sep 2026 08:24:59 -0700 Subject: [PATCH 11/30] docs(m3): record sales-ops answers on the Apex endpoint fields Midhun confirmed on 2026-09-30: a new External ID field on Account, domain match on Website only, EasyCLA as a new AccountSource picklist value, and the integration user as owner of created accounts. Sandbox rsmdev access granted. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 0d2d17d6e..6ab96ed02 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -61,9 +61,9 @@ Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assign ## 4. Salesforce side (to agree with sales ops) -- **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website` / `Domain_Alias__c`). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. -- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Domains on a shared-domain list (`github.com`, `nowebsite.com`, mail providers) never match; the tool sends those companies to manual instead (17 today). Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`), set on create only; a repeat call for a matched Account matches it again by domain. No usable External ID field exists today. -- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` = `Company_Account` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, `AccountSource = "EasyCLA"` as the origin marker (Mindy's ask on the 2026-09-29 call; a new picklist value, Midhun to confirm the field), `OwnerId` (a queue). +- **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website`; Midhun, 2026-09-30). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. +- **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Domains on a shared-domain list (`github.com`, `nowebsite.com`, mail providers) never match; the tool sends those companies to manual instead (17 today). Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`; a new field, Midhun 2026-09-30), set on create only; a repeat call for a matched Account matches it again by domain. +- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` = `Company_Account` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, `AccountSource = "EasyCLA"` as the origin marker (Mindy's ask on the 2026-09-29 call; new picklist value confirmed by Midhun, 2026-09-30), `OwnerId` = the Salesforce integration user (`Linux Foundation (API) User`, Midhun 2026-09-30); it is also the caller, so Apex leaves the owner at the default. - **Signing entities**: one `Account` ↔ one or more `company_external_id`s. Rows that share an ID are rewritten together; IDs approved as a collapse (§3 step 2) land on the same Account. All of them become the Account's signing entities; `signing_entity_name` stays in EasyCLA. EasyCLA already keeps several rows behind one SFID; surfacing all of them is #2056's resolution logic. No child accounts in M3. - **Bulk**: the tool calls the endpoint once per distinct `company_external_id` (at most ~1,300 calls, in tranches). One dedupe path for bulk and sweep. @@ -88,8 +88,8 @@ No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it | # | Question | Owner | | --- | --- | --- | -| 1 | Approve the Apex endpoint, the External ID field and the field values (§4) | Mindy / Midhun | -| 2 | Sandbox access and review cadence for the Apex contribution | Midhun | +| 1 | Review of the Apex endpoint (§4); field values and owner agreed 2026-09-30 | Mindy / Midhun | +| 2 | Review cadence for the Apex contribution (sandbox `rsmdev` access granted 2026-09-30) | Midhun | | 3 | Confirm v4 creation moves to the Member Service post-M3; M3 relies on the sweep | Eric | | 4 | Does ACS need the org in the Org Service when an org scope is created? (test on dev) | us | | 5 | Remove the Member-only filter from the member-service B2B backfill | Eric | @@ -107,3 +107,4 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. - **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. - **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships before the lens CLA tab (Lukasz keeps it in #5227 for time, tested on dev after merge); the shared `path-companyID` widening flagged on the PR. +- **2026-09-30** Midhun (Slack): new External ID field on `Account`; domain match on `Website` only; `EasyCLA` added as an `AccountSource` picklist value; created accounts owned by the integration user; sandbox `rsmdev` access granted. From 0ac7dbdff3dd622e9fdf796c3c4fa032985d701e Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Wed, 30 Sep 2026 16:05:05 -0700 Subject: [PATCH 12/30] docs(m3): record access setup, Luis's review and Eric's Option A Auth0 grant applied; global_org_admin tuple is hand-written per environment. Luis's #5227 review verified: liveness GET 403s for never-registered non-members and 15-char IDs, registration is one-way, so dev only and member Accounts only until the backfill filter goes. Eric: Self-Service creates new B2B orgs, not EasyCLA; drop the filter entirely; legacy-id field questioned. Prod tuple sequenced against member-service#120. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 6ab96ed02..245c3deb4 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -51,7 +51,7 @@ Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749), [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054)): the 18 unresolvable companies and the 43 without a usable ID. Both sets are handled by hand; the ingest tool never creates Org Service orgs (`CreateOrg`), only Salesforce Accounts through §4. 2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. 3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. -4. **Verify per tranche**: the CRM Account exists (member-service `GET /b2b_orgs/{id}`, which reads Salesforce; the Org Service also serves IDs the CRM no longer has, §2, so it is not a liveness check); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. +4. **Verify per tranche**: the CRM Account exists (member-service `GET /b2b_orgs/{id}`, which reads Salesforce; the Org Service also serves IDs the CRM no longer has, §2, so it is not a liveness check; Heimdall answers 403 until the org's FGA tuples land, which is asynchronous after step 9 of §5, so retry before treating it as missing); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. 5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) with the tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)), tested on dev with an org that has no row, before the lens CLA tab reaches prod. 6. **M3 GA.** 7. **Daily sweep** = the same tool on a schedule (GitHub Actions, `register` route only), for newly signed CCLAs: companies whose ID is a live CRM Account are registered with member-service; the rest (new `lf…` orgs, since v4 `CreateOrg` keeps creating Org Service orgs until §6) are listed for the sales-ops mapping CSV, or created through Apex once §4 is agreed. Either way an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). @@ -79,22 +79,26 @@ The tool runs once per distinct `company_external_id`. Several EasyCLA rows shar 6. Copy every ACS scope that carries the old ID (any role; org and project|org scopes) to `newId`. EasyCLA authorizes lens and Console calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so the new scopes must exist before step 7. 7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). Rewrite the company's events as well — `event_company_sfid` and the `company_sfid_*` composite GSI keys: the Console activity log and `GET /v4/company/{id}/project/{sfid}/events` query events by SFID ([`events/repository.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/events/repository.go), `GetCompanyClaGroupEvents`), so pre-import history would disappear otherwise. 8. Delete the scopes on the old ID. -9. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). +9. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). Needs the tool's Auth0 client granted `lfx_v2_api` ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395), applied to dev, staging and prod) and `user:@clients member team:global_org_admin` in OpenFGA, written by hand per environment (team membership is hand-maintained; Eric, 2026-09-30). 10. Log each completed step: `old_id, new_id, action, step, timestamp`. No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. +Registration has no undo (no `DELETE /b2b_orgs/{id}`). Until the backfill filter is dropped (§6 item 5), `GET /b2b_orgs/{id}` returns 403 for a non-member Account that was never registered, so step 1 cannot classify it, and a registered non-member drops out of search on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. + ## 6. Open questions and post-M3 | # | Question | Owner | | --- | --- | --- | | 1 | Review of the Apex endpoint (§4); field values and owner agreed 2026-09-30 | Mindy / Midhun | | 2 | Review cadence for the Apex contribution (sandbox `rsmdev` access granted 2026-09-30) | Midhun | -| 3 | Confirm v4 creation moves to the Member Service post-M3; M3 relies on the sweep | Eric | +| 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | | 4 | Does ACS need the org in the Org Service when an org scope is created? (test on dev) | us | -| 5 | Remove the Member-only filter from the member-service B2B backfill | Eric | +| 5 | Remove the Member-only filter from the member-service B2B backfill; Eric: drop it entirely and ingest all Accounts (2026-09-30). Unblocks non-member registration (§5) | member-service team | +| 6 | Prod tuple for the tool's client: write it before `snapshot`/`plan` or after `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)), and finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Luis | +| 7 | Eric doubts the Account needs the legacy EasyCLA id field (§4 `LFX_Org_Id__c`); §4 uses it for idempotency on `externalKey`, so confirm before the Apex review | us | -Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. +Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. ## 7. Decision log @@ -108,3 +112,5 @@ Post-M3, parked: v4 `CreateCompany` creates through the Member Service and CLA-m - **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. - **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships before the lens CLA tab (Lukasz keeps it in #5227 for time, tested on dev after merge); the shared `path-companyID` widening flagged on the PR. - **2026-09-30** Midhun (Slack): new External ID field on `Account`; domain match on `Website` only; `EasyCLA` added as an `AccountSource` picklist value; created accounts owned by the integration user; sandbox `rsmdev` access granted. +- **2026-09-30** Access for the tool: Auth0 grant merged and applied to dev, staging and prod ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)); `team:global_org_admin` membership is hand-maintained, so the tuple is written directly in OpenFGA, dev first (Eric). Dev member-service Salesforce password rotated ([linuxfoundation/lfx-self-serve-ops#208](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/208)). Luis's review of #5227 verified against member-service `main`: the liveness GET 403s for never-registered non-member Accounts and for 15-character IDs, and registration is one-way; dev only and member Accounts only until §6 item 5 lands, prod sequenced against member-service#120. +- **2026-09-30** Eric ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6)): EasyCLA should not call `POST /b2b_orgs` as a side effect of creating an EasyCLA org; the Self-Service backend creates the B2B org and the CCLA carries the `001…` id (Option A); the backfill filter should go entirely; the legacy-id Account field may be unnecessary. A recommendation, not a mandate; the one-time import is unaffected. From d793b39b5479c50a82dbbe6177c6cb810cca9a6e Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Wed, 30 Sep 2026 22:42:46 -0700 Subject: [PATCH 13/30] docs(m3): record the dev global_org_admin tuple Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 245c3deb4..6c692d63d 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -114,3 +114,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-09-30** Midhun (Slack): new External ID field on `Account`; domain match on `Website` only; `EasyCLA` added as an `AccountSource` picklist value; created accounts owned by the integration user; sandbox `rsmdev` access granted. - **2026-09-30** Access for the tool: Auth0 grant merged and applied to dev, staging and prod ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)); `team:global_org_admin` membership is hand-maintained, so the tuple is written directly in OpenFGA, dev first (Eric). Dev member-service Salesforce password rotated ([linuxfoundation/lfx-self-serve-ops#208](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/208)). Luis's review of #5227 verified against member-service `main`: the liveness GET 403s for never-registered non-member Accounts and for 15-character IDs, and registration is one-way; dev only and member Accounts only until §6 item 5 lands, prod sequenced against member-service#120. - **2026-09-30** Eric ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6)): EasyCLA should not call `POST /b2b_orgs` as a side effect of creating an EasyCLA org; the Self-Service backend creates the B2B org and the CCLA carries the `001…` id (Option A); the backfill filter should go entirely; the legacy-id Account field may be unnecessary. A recommendation, not a mandate; the one-time import is unaffected. +- **2026-09-30** Dev tuple written by hand through an OpenFGA port-forward: `user:@clients member team:global_org_admin` (client ID confirmed against the #395 apply log). OpenFGA `check` confirms `member` on the team and `auditor` on a registered org, so both tool routes are authorized in dev. Staging and prod tuples not written. Lukasz to rerun the dev dry-run; member Accounts only. From bb4539e3f303e6794779bd2c0348b59a828ef6c8 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 5 Oct 2026 19:00:19 -0700 Subject: [PATCH 14/30] docs(m3): address PR #5223 review feedback, record 2026-10-05 decisions Address review comments from copilot-pull-request-reviewer[bot]: - Phase 0, section 5, section 6 item 5: a liveness GET on a dead or never-registered ID answers 403 (Heimdall checks auditor on the org), which the tool reports as an error; the 403 follow-up is open (not in #5236) and blocks the dead-001 rewrite tranches. The register POST reads the Account directly, so its 404 means it does not exist. - Section 5 step 9: register the current ID (newId after a rewrite), so the register-only branch has a defined value. - Section 2: the active-CCLA predicate applies to any row of the company_external_id group; an eligible group is rewritten whole. Also record the 2026-10-05 inputs: Eric's decision that b2b_orgs mirrors every Salesforce Account and Prabodh's assessment; sales ops' answers on the 2023 removals; the Contributor Console search as a second ghost-company exposure; the Salesforce / Org Service history in section 1.1; and fix stale lines that still credited matching to Snowflake. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 220 +++++++++++++++--- 1 file changed, 190 insertions(+), 30 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 6c692d63d..b6a708218 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -3,12 +3,21 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3: getting EasyCLA companies into Salesforce -**Status**: draft 2026-09-24 · **Owner**: Michal (engineering) +**Status**: draft 2026-10-05 · **Owner**: Michal (engineering) **Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) (unreachable companies) · [#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085) (duplicate review) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) -**Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) +**Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) · tool runbook [`cmd/org_import/README.md`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/cmd/org_import/README.md) · manual cleanup runbook [`m3-org-cleanup.md`](https://github.com/linuxfoundation/easycla/blob/dev/docs/easycla-ss-migration/m3-org-cleanup.md) This file is the single place for the plan and its decisions. Tickets carry pointers only. Append to the decision log (§7); do not rewrite history. +## 0. Summary + +- **Goal**: every EasyCLA company with an active CCLA points at a live Salesforce Account (`001…`) and is registered in member-service, so it appears in the Self Serve Organization lens. Internal `company_id`s never change; the import deletes or merges no EasyCLA row. +- **Tool**: Lukasz's `org-import` ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227), merged, on dev). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. +- **Order** (§3): prod audit and review pack → dev pilot → prod `register` (member Accounts first) → prod `rewrite` tranches 10 / 100 / rest → manual set → daily sweep. Duplicate companies collapse onto one Account inside the rewrite tranches, after a human decision; row merges stay post-import. +- **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. +- **Blocked today**: prod access for the tool (tuple sequenced after [lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)); the member-service qualifying rule for non-member Accounts (§6 item 5, gates non-member registration in prod); prod deploy of #5227; the tool's liveness-403 follow-up (blocks the dead-`001…` rewrites, §3 Phase 0); sales-ops time for the mapping. +- **Not in M3**: switching v4 org creation to Salesforce (Eric's Option A, post-M3); row merges (#2056); deleting the ~1.4k companies without an active CCLA. + ## 1. One Salesforce org, and where the IDs come from What earlier notes called the "old platform org" is the **Organization Service's Postgres table**, not a second Salesforce. @@ -17,21 +26,49 @@ What earlier notes called the "old platform org" is the **Organization Service's | --- | --- | --- | --- | | Salesforce (the CRM, "B2B") | The only Salesforce org | `001…` Id, assigned by Salesforce | Sales ops, Apex `JoinNowForm` | | `salesforce_b2b."Account"` (platform Postgres) | Replica of the CRM, Stitch sync every 30 min | `Id` | Stitch | -| `salesforce.account` (platform Postgres, the **Org Service** table) | Heroku Connect copy of Salesforce until 2023-04-04; since then written by the Org Service only | `sfid`: `001…` copied from the CRM, or `lf…` generated by the Org Service | Org Service (`lf…`), a DB trigger (`001…`) | +| `salesforce.account` (platform Postgres, the **Org Service** table) | Heroku Connect copy of Salesforce until mid-June 2023 (Salesforce writes removed from the code 2023-04-04, first `lf…` org 2023-06-18); since then written by the Org Service only | `sfid`: `001…` copied from the CRM, or `lf…` generated by the Org Service | Org Service (`lf…`), a DB trigger (`001…`) | | EasyCLA `cla-prod-companies` | CCLA company | `company_id`; `company_external_id` holds an Org Service `sfid` | EasyCLA v3/v4 | +| member-service `b2b_org` | The Organization lens catalogue | the `001…` Id | member-service (`POST /b2b_orgs`, backfill) | + +```mermaid +flowchart LR + CRM["Salesforce CRM
Account Id 001…
(the only Salesforce org)"] + REP["salesforce_b2b.Account
Stitch replica, every 30 min"] + OS["Org Service salesforce.account
sfid = 001… (copied) or lf… (minted)"] + CLA["EasyCLA cla-prod-companies
company_external_id = an Org Service sfid"] + ACS["ACS role scopes
keyed by that sfid"] + MS["member-service b2b_org
Org lens catalogue, keyed by 001…"] + CRM -->|Stitch| REP -->|DB trigger: insert when unknown| OS + OS -->|v4 CreateOrg, Console search| CLA + CLA -->|CLA-manager role from signature_acl| ACS + CRM -->|"POST /b2b_orgs (register an existing Account)"| MS + CLA -.->|"import: rewrite to a live 001…, copy ACS scopes, register"| MS +``` Evidence: -- Org Service stopped writing Salesforce on 2023-04-04 and mints its own IDs: [`organization/repository.go`](https://github.com/linuxfoundation/organization-service/blob/main/organization/repository.go) (`CreateOrganization`: "we are not using the SFDC integration to create or update account records", `platformSFID := common.GenerateID()`). `lf…` IDs come from [`lfx-kit/common/id.go`](https://github.com/linuxfoundation/lfx-kit/blob/main/common/id.go). +- Org Service stopped writing Salesforce on 2023-04-04 (in prod by mid-June 2023, see §1.1) and mints its own IDs: [`organization/repository.go`](https://github.com/linuxfoundation/organization-service/blob/main/organization/repository.go) (`CreateOrganization`: "we are not using the SFDC integration to create or update account records", `platformSFID := common.GenerateID()`). `lf…` IDs come from [`lfx-kit/common/id.go`](https://github.com/linuxfoundation/lfx-kit/blob/main/common/id.go). - The CRM → Org Service copy is a Postgres trigger, `salesforce.function_create_update_b2c_account` ([2023-08-10](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230810205345_salesforce-account-trigger-create.sql), [2023-09-05](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230905220112_account_b2b_procedure_updates.sql)). On insert or update of a replica row it looks for `salesforce.account WHERE sfid = new."Id"` only; no match → inserts a row with `sfid = new."Id"` and `sfid_b2b = new."Id"`. Live in prod (Eric, TECHNICAL.md §7.3, 2026-09-15). - The domain-based linker `update_account_sfid_b2b_value()` is not scheduled, so `lf…` rows never get `sfid_b2b` (Eric, TECHNICAL.md §1). What follows: 1. `sfid_b2b == sfid` for every CRM account because the trigger copies the Id. Latency CRM → Org Service ≈ 0–40 min (Stitch interval + trigger). -2. **Importing into Salesforce while keeping an existing ID is impossible.** Salesforce assigns Ids; nothing in LFX ever pushed one in. "Path A" in #2750 is dropped. +2. **Importing into Salesforce while keeping an existing ID is impossible.** Salesforce assigns Ids; nothing in LFX ever pushed one in (confirmed again with David, standup 2026-10-01). "Path A" in #2750 is dropped. 3. **Every company we create or match an account for gets a new `company_external_id`.** Only companies whose ID already resolves to a live CRM account stay untouched. -4. Creating an account for an `lf…` company leaves the `lf…` row orphaned next to the new `001…` shadow row. Harmless: EasyCLA and the Corporate Console follow `company_external_id` to the shadow row. The Org Service is being retired. +4. Creating an Account for a rewritten company adds a second Org Service row for the same organization: the trigger inserts the new `001…` and the old `lf…` or dead `001…` row stays (the domain linker is not scheduled, nothing deletes it). About 1,290 such pairs after the import, next to the 962 dead `001…` rows the Org Service already serves today. EasyCLA and the Corporate Console follow `company_external_id`, so the rewritten company itself is unaffected. The exposures are the two lookups that search the Org Service by name: the Corporate Console's create-company lookup (`GET /v4/company/lookup`) and the Contributor Console's company search (`GET /v3/organization/search`). Both can return the old row, listed without a CCLA; a user who picks it creates a second EasyCLA company with a dead ID (the company-by-external-ID endpoints auto-create from the Org Service record), and a contributor's CLA-manager request then reaches nobody. That company then shows up as a new group for the monthly batch (§3 Phase 6) and lands on the same Account with a `collapse` decision. Accepted for the parallel window, checked on samples in Phase 4; deleting or renaming orphan rows in the retiring Org Service is not planned. Fallback if ghosts appear: EasyCLA hides the IDs recorded in `previous_company_external_id` from these lookups and redirects the auto-create to the new company. + +### 1.1 How we got here (checked against the prod Snowflake mirrors, 2026-10-02) + +| When | What happened | Evidence | +| --- | --- | --- | +| 2008 → today | One Salesforce org, continuous: live CRM Accounts carry creation dates from 2008 on, about 1,000–2,400 per year | `FIVETRAN_INGEST.SALESFORCE.ACCOUNT`, `CREATED_DATE` by year | +| until mid-2023 | The Org Service table was a Heroku Connect copy of Salesforce (`salesforce.account`, `sfid`, `heroku_connect_id__c`), and the Org Service created organizations **in Salesforce** through its API user "Platform Service". Those Accounts got real `001…` Ids, which is why the Org Service and Salesforce share Ids | 18,539 of the 18,540 live CRM Accounts exist in the Org Service table under the same Id, 18,453 with the same creation timestamp | +| 2020 → 2023-06 | About 58,000 company Accounts created in Salesforce in that window (33k by "Platform Service", 22k by two bulk loads, the rest by sales users) are not in the CRM today: 11k from 2020, 12k from 2021, 22k from 2022, 7k from Jan–Jun 2023. They survive only as Org Service rows, last synced 2023-06-16. They are company accounts, not person accounts (57,871 of 57,887) | Org Service `001…` rows absent from the CRM, by `CREATEDDATE`, `CREATEDBYID`, `SYSTEMMODSTAMP` | +| 2023-04 → 2023-09 | The Org Service dropped its Salesforce integration (code 2023-04-04/07, first `lf…` org 2023-06-18). The Stitch replica `salesforce_b2b` and the `sfid_b2b` column arrived in April 2023 ("we assign the existing B2C account ID to the B2B account ID"), the CRM → Org Service trigger in Aug/Sep 2023. "B2B" is the name that replica got; it is the same Salesforce | migrations [2023-04-07](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230407205238_account-add-salesforce-id-column.sql), [2023-05-09](https://github.com/linuxfoundation/lfx-core-platform-database-migrations/blob/main/db/migrations/20230509184859_b2b-account-table-docs.sql), 2023-08-10, 2023-09-05; organization-service [#727](https://github.com/linuxfoundation/organization-service/pull/727) | +| 2026-06 | Direct Fivetran connector to the CRM (`FIVETRAN_INGEST.SALESFORCE`); the Stitch mirror in Snowflake is stale since 2026-06-24 | TECHNICAL.md §2 | + +Two things this rules out. The CRM was **not** created recently by copying a subset of Org Service organizations: its Ids and creation dates go back to 2008, and nothing in LFX can write an Id into Salesforce. And the dead `001…` IDs in EasyCLA are **not** recoverable from the CRM: of EasyCLA's 3,014 distinct `001…` IDs, 1,639 are absent from the CRM mirror outright, none sit in the recycle bin and none carry a `MasterRecordId` (the whole mirror has 12 deleted Accounts and 6 with `MasterRecordId`). They were removed from Salesforce after June 2023 in a way the mirrors cannot see: sales ops removed them as non-B2B in the 2023 CRM project, and they cannot be reinstated (§6 item 10). ## 2. Populations (prod Snowflake mirrors, 2026-09-18/19; details in #2749) @@ -43,60 +80,173 @@ What follows: | Resolves nowhere | 18 | Manual | | No usable `company_external_id` (empty or malformed; 2026-09-29) | 43 | Manual: no Org Service row, so no website. 5 have an exact CRM name match; 28 have no ECLA. Listed in [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) | -Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 'company'`, signed and approved), evaluated at run time. ID shape decides the route, not eligibility. Companies without an active CCLA stay in EasyCLA untouched; their disposition before Console retirement is an exit criterion of #2750. Growth: ~16 new CCLA companies per month. +The tool's prod audit (Lukasz, 2026-10-01, DynamoDB) gives the same picture: 1,954 distinct `001…` IDs across 1,958 companies, 332 `lf…`, 43 manual, 1,390 without an active CCLA. + +Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 'company'`, signed and approved) on any row of the `company_external_id` group, evaluated at run time; an eligible group is rewritten whole. ID shape decides the route, not eligibility. Companies without an active CCLA stay in EasyCLA untouched; their disposition before Console retirement is an exit criterion of #2750. Growth: ~16 new CCLA companies per month. + +The excluded set is large because EasyCLA minted rows without signing intent: 1,380 of 3,689 prod companies (37%) have no active CCLA and 1,237 have no CCLA record at all (#2749, 2026-09-18). Most of those came from read paths — company search with signing entities, `GET /company/external/{sfid}`, the v4 lookups by SFID — that persisted a row for every organization a user merely looked at (#2751). The pure-read fix is on dev with #5227; prod gets it with the next tag deploy (§3, Phase 0). Until then prod keeps growing that set. + +### 2.1 What happens to each company + +| Population (prod) | Count | Tool route | Who decides what | Phase (§3) | +| --- | ---: | --- | --- | --- | +| Active CCLA, `001…` live in the CRM | 995 | `register` | nobody per company (the Account exists); humans verify each tranche | 3 | +| Active CCLA, dead `001…`, website domain matches one live Account | 374 | `rewrite` | a human approves each match | 4 | +| Active CCLA, dead `001…`, no match | 588 | `rewrite` | sales ops create the Account from a reviewed list | 4 | +| Active CCLA, `lf…`, domain match | 57 | `rewrite` | a human approves each match | 4 | +| Active CCLA, `lf…`, no match | 271 | `rewrite` | sales ops create the Account | 4 | +| Same organization under several IDs (~30 groups, 78 rows; #3085) | — | `rewrite` with a `collapse` decision | a named reviewer marks each group `collapse` or `distinct` | 4 | +| Active CCLA, unresolvable: dead everywhere (18 `001…` + 3 `lf…`), empty or malformed ID (43); counts drift a little between mirror and live audit | 64 | `manual` → mapping keyed by `company_id`, or leave | a human per row | 5 | +| No active CCLA (incl. the rows created by reads) | ~1,400 | untouched | product: disposition before Console retirement | 7 | +| New CCLA companies after the import | ~16 / month | sweep `register`; new `lf…` groups → monthly rewrite batch | as above | 6 | ## 3. Plan -0. **Agree the Salesforce side with sales ops** (§4). No code before this. -1. **Cleanup** ([#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749), [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054)): the 18 unresolvable companies and the 43 without a usable ID. Both sets are handled by hand; the ingest tool never creates Org Service orgs (`CreateOrg`), only Salesforce Accounts through §4. -2. **Duplicate review** ([#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085)): a report of active-CCLA companies that share a website domain or a normalized name under different IDs (78 rows in ~30 groups, 2026-09-28). A human marks each group *collapse* (one Account; the rows become its signing entities) or *distinct* (separate legal entities: NASA Ames / Goddard, SAP / SAP Labs Bulgaria). Approved collapses feed the dry run (§5 step 3). Row merges — deleting a row and re-pointing its CCLA, ECLAs and users — stay out of the import: after it the rows already share one Account, and [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) becomes the post-import merge runbook. -3. **Ingest tool** (EasyCLA `utils/`, idempotent, dry-run first), as in §5. Tranches 10 → 100 → rest. -4. **Verify per tranche**: the CRM Account exists (member-service `GET /b2b_orgs/{id}`, which reads Salesforce; the Org Service also serves IDs the CRM no longer has, §2, so it is not a liveness check; Heimdall answers 403 until the org's FGA tuples land, which is asynchronous after step 9 of §5, so retry before treating it as missing); `GET /v4/company/external/{id}/cla-groups` returns the CLA groups; the Console shows the company to its managers. -5. **FGA and lens per tranche**: `cla_admin` tuples from `signature_acl`; check the Organization lens. The lens reads `GET /v4/company/external/{id}/cla-groups`, which today creates a company row on read; [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) makes the read paths non-mutating (the row is created at CCLA request) with the tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)), tested on dev with an org that has no row, before the lens CLA tab reaches prod. -6. **M3 GA.** -7. **Daily sweep** = the same tool on a schedule (GitHub Actions, `register` route only), for newly signed CCLAs: companies whose ID is a live CRM Account are registered with member-service; the rest (new `lf…` orgs, since v4 `CreateOrg` keeps creating Org Service orgs until §6) are listed for the sales-ops mapping CSV, or created through Apex once §4 is agreed. Either way an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). -8. **Parallel window, then retire the Console**, gated on the disposition of excluded companies. +Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-05. + +### Phase 0 — prerequisites (in progress) + +| Item | Owner | Status | +| --- | --- | --- | +| Tool merged and deployed to dev; pure-read company endpoints (#2751) verified on dev | Lukasz | done (`d0601e9`, 2026-09-30) | +| Dev access: Auth0 grant ([auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)), `team:global_org_admin` tuple, member-service Salesforce password | Michal / platform | done 2026-09-30 | +| Tool follow-up: normalise IDs to 18 characters before the liveness GET; report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite). Blocks the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | open; not in [linuxfoundation/easycla#5236](https://github.com/linuxfoundation/easycla/pull/5236) (merged 2026-10-01) | +| Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service | Lukasz | proposed 2026-10-05 | +| member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every Salesforce Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05). Dev check: register a non-member Account, edit one field in Salesforce, confirm its `b2b_org` document survives | member-service team (Prabodh) | direction confirmed 2026-10-05; [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) 2026-10-05: one-file change plus a switch, about 10,300 more Accounts, excludes sanctions-screening, project and personal Accounts, follow-ups on family access, LFX One slug ties and a gradual rollout; no date; gates non-member registration in prod | +| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Luis | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple and deploy open | +| Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | hand-off to agree; Apex not on the M3 critical path | + +### Phase 1 — prod audit and review pack (now, read-only) + +1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. +2. Domains and candidate matches, automated (read-only). The tool knows only the Org Service website; for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching asked of the tool (Phase 0). +3. One review sheet for Heather, Juansebastian and sales ops, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. +4. Monthly statistics for the standup: companies created per month, split by "has a CCLA" / "none", so the premature-row rate before and after the #2751 fix is visible. +5. Outputs: `map.csv` (§4), `decisions.csv` (runbook §4.1), the shared-domain list, the manual-set decisions recorded in #2749. + +Done when: every `matched` and every collapse has a named reviewer (Heather, Juansebastian or anyone on the EasyCLA team), and sales ops have the to-create list. + +### Phase 2 — dev pilot + +`ingest --apply --ids …` on a handful of dev groups, both routes, with a dev mapping; runbook §5 verification (member-service GET, Org Service GET, ACS scopes moved, `cla-groups` lists the CCLA, events history intact, Console and lens show the company). Only the member-service GET proves the Account is live: the Org Service also serves IDs the CRM no longer has, and Heimdall answers 403 until the org's FGA tuples land, which is asynchronous after registration, so retry before treating a fresh registration as missing. Include Ahmed's non-member edit check once the member-service change is on dev. Confirm with Lukasz that a prod `register` run only POSTs groups whose liveness GET returned 200 while 403 groups wait. + +### Phase 3 — prod `register` (995 groups, no Salesforce writes) + +Prod deploy, prod dry run, then `ingest --routes register --tranche 10 --apply` → verify three groups → 100 → rest. Member Accounts first (their GET returns 200 today); non-member Accounts only after the member-service rule change (Phase 0), otherwise they drop out of the lens index on their next Salesforce edit. Registration has no API undo, so never without member-service and never for an ID the CRM does not serve. -Dropped from M3: switching v4 org creation to Salesforce. `CreateCompany` assigns ACS org scopes right after creating the org, and a Salesforce-created account is invisible to the Org Service, ACS and the Console for up to ~40 min. Doing it properly means async role assignment or moving CLA-manager authorization off ACS, which is Eric's Member Service work (§6). +Done when: a second run reports `registered=0`, and the three sampled companies are visible in the lens to a user who already holds a `b2b_org` grant. CLA-manager entry into the lens is the `cla_admin` relation ([m3-fga-model.md](https://github.com/linuxfoundation/easycla/pull/5210)), separate work. + +### Phase 4 — prod `rewrite` (~1,290 groups, mapping CSV) + +Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. + +Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID, and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. + +### Phase 5 — manual set (64 companies) + +Per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749. Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. + +### Phase 6 — steady state until Option A + +- Daily sweep, `register` route only (`org-import-sweep.yml`): after ≥10 clean manual runs set `ORG_IMPORT_SWEEP_PROD=dry-run`, read the reports for a week, then `apply`. Newly signed CCLAs on live Accounts appear in the lens within a day. On both paths an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). +- New `lf…` groups (v4 `CreateOrg` keeps minting them, ~16/month) show up as `pending` → one mapping batch per month through Phases 1 and 4, owner Michal. +- #2751 in prod stops the premature rows; the monthly statistics (Phase 1 item 4) confirm it. + +### Phase 7 — after the import, before Console retirement + +- **Row merges** (#2056): default is to leave duplicate rows unmerged and documented. Signed PDF keys embed `company_id` and ACLs, invites and ECLAs point at the row; a merge is a separate, dry-runnable procedure with unchanged CCLA/ECLA counts as its acceptance check. +- **~1.4k companies without an active CCLA**: proposal — leave them in EasyCLA until the Console is retired; then export and delete only rows with no signature of any kind (1,237), keep every row with CCLA or ECLA history (legal documents). Decided 2026-10-02 (Michal; Heather informed); the export format is agreed in #2749 before retirement. +- **Option A** (§6 item 3): the Self-Service backend creates the B2B org through member-service and the CCLA carries the `001…` id; EasyCLA stops minting `lf…` orgs and the monthly batch ends. Not in M3 because `CreateCompany` assigns ACS org scopes right after creating the org, and a Salesforce-created account is invisible to the Org Service, ACS and the Console for up to ~40 min; doing it properly means async role assignment or moving CLA-manager authorization off ACS (Eric's Member Service work). + +### 3.8 Who decides what + +| Step | Decided by | Tool's part | +| --- | --- | --- | +| An ID is live in the CRM | fact (member-service GET) | classifies, registers | +| A dead or `lf…` company equals an existing Account | a person, per match (`approved=true`, reviewer named) | suggests an Account (follow-up, Phase 0); refuses unapproved matches | +| A new Account is created | sales ops, from the reviewed to-create list | none in M3 (Apex path off) | +| Several IDs are one organization | a named reviewer, per group (`collapse` / `distinct`) | refuses collisions without a decision | +| Unresolvable row | a person, per row | lists it with a suggested action | +| ACS copy, row rewrite, events re-key, registration | tool, idempotent, journaled, conditional writes | all of it | +| Row merges | a person, post-import, default none | never | +| Disposition of companies without an active CCLA | product, before Console retirement | never touches them | + +The tool and AI (matching, drafting) propose; the sheet marks proposals as such; a person approves. Complex cases (shared domains, ambiguous names, legal-entity splits) are always a human call (Heather, standup 2026-10-01). + +### 3.9 Clean up, merge, fix — what each means here + +- **Fix** = make the company reachable from the lens: `register` or `rewrite`. The tool. +- **Merge** has two levels. *Account level*: several EasyCLA IDs land on one Account and become its signing entities (a `collapse`, inside Phase 4, human-approved). *Row level*: deleting an EasyCLA row and re-pointing its CCLA, ECLAs, ACL and PDFs (#2056, Phase 7, default not done). +- **Clean up** = the manual set (Phase 5), stopping new premature rows (#2751 in prod), and the disposition of companies without an active CCLA (Phase 7). Nothing is deleted before Console retirement. ## 4. Salesforce side (to agree with sales ops) +Two ways to obtain the new IDs. **M3 uses the mapping CSV**; the Apex endpoint is for the ongoing path. + +- **Mapping CSV (M3 bulk, runbook §4)**: `to_salesforce.csv` from the dry run plus the tool's proposed matches (Phase 1) → sales ops confirm each match and create the rest in Data Loader with the field values below → `old_id,new_id,action,approved`. No Apex dependency, sales ops keep every CRM write in their hands, and a one-shot load needs no external-ID field (Eric's doubt in §6 item 7). Owner of the file: sales ops; owner of the review: Michal. +- **Apex find-or-create (ongoing)**: member-service's `POST /b2b_orgs` will create Accounts through it once the Self-Service backend creates B2B orgs (Option A, §6 item 3). The tool can call it too (`--use-apex`, off in M3; no `cla-salesforce-apex-base-url-*` parameters exist). + +Apex details as agreed so far: + - **Mechanism**: a new Apex `@RestResource` in [`LF_SF_CICD_Setup`](https://github.com/linuxfoundation/LF_SF_CICD_Setup) that finds or creates an `Account` by domain (`Website`; Midhun, 2026-09-30). Sales ops asked for an Apex interface, not direct sObject create, and accept an AI-written contribution tested in sandbox (2026-09-17). `JoinNowForm` is not reusable: it matches on exact Name + Website and writes placeholder websites on domain clashes. - **Contract**: `POST /services/apexrest/lfx/account` with `{ name, website, source: "EasyCLA", externalKey: , cclaSignedDate, dryRun }` → `{ id, action: "matched" | "created" | "ambiguous" }`. `ambiguous` means more than one Account has the domain: nothing is written and the org goes to manual. `dryRun` reports the action without writing. Domains on a shared-domain list (`github.com`, `nowebsite.com`, mail providers) never match; the tool sends those companies to manual instead (17 today). Idempotent on `externalKey` via a new unique External ID field on `Account` (e.g. `LFX_Org_Id__c`; a new field, Midhun 2026-09-30), set on create only; a repeat call for a matched Account matches it again by domain. -- **Field values**: `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` = `Company_Account` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, `AccountSource = "EasyCLA"` as the origin marker (Mindy's ask on the 2026-09-29 call; new picklist value confirmed by Midhun, 2026-09-30), `OwnerId` = the Salesforce integration user (`Linux Foundation (API) User`, Midhun 2026-09-30); it is also the caller, so Apex leaves the owner at the default. -- **Signing entities**: one `Account` ↔ one or more `company_external_id`s. Rows that share an ID are rewritten together; IDs approved as a collapse (§3 step 2) land on the same Account. All of them become the Account's signing entities; `signing_entity_name` stays in EasyCLA. EasyCLA already keeps several rows behind one SFID; surfacing all of them is #2056's resolution logic. No child accounts in M3. -- **Bulk**: the tool calls the endpoint once per distinct `company_external_id` (at most ~1,300 calls, in tranches). One dedupe path for bulk and sweep. +- **Field values** (both paths): `RecordTypeId` of the record type the Org Service serves (`01241000000bkf1AAA` = `Company_Account` in prod; Apex resolves it by `DeveloperName`), `Type`, `IsMember__c = false`, `AccountSource = "EasyCLA"` as the origin marker (Mindy's ask on the 2026-09-29 call; new picklist value confirmed by Midhun, 2026-09-30), `OwnerId` = the Salesforce integration user (`Linux Foundation (API) User`, Midhun 2026-09-30); it is also the Apex caller, so Apex leaves the owner at the default. +- **Signing entities**: one `Account` ↔ one or more `company_external_id`s. Rows that share an ID are rewritten together; IDs approved as a collapse (§3 Phase 1) land on the same Account. All of them become the Account's signing entities; `signing_entity_name` stays in EasyCLA. EasyCLA already keeps several rows behind one SFID; the lens lists all of them (§3 Phase 4). No child accounts in M3. +- **Bulk**: one Account decision per distinct `company_external_id` (at most ~1,300, in tranches). One dedupe path for bulk and sweep. ## 5. Per-company sequence (the ingest tool) +```mermaid +flowchart TD + A["Group = rows sharing one company_external_id"] --> B{"Active CCLA on any row?"} + B -- no --> U["Untouched (disposition before Console retirement)"] + B -- yes --> C{"ID shape"} + C -- "empty / malformed" --> M["manual: a person picks or creates the Account;
mapping keyed by company_id"] + C -- "001…" --> D{"Live in the CRM?
(member-service GET)"} + D -- yes --> R["register: POST /b2b_orgs"] + C -- "lf…" --> E{"Mapping row?"} + D -- no --> E + E -- "matched + approved, or created" --> W["rewrite: copy ACS scopes, rewrite rows,
re-key events, delete old scopes, register"] + E -- "none / ambiguous / unapproved" --> P["pending: to_salesforce.csv → sales ops"] + W --> F{"Another ID lands on the same Account?"} + F -- "collapse decision" --> OK["done: rows are signing entities of one Account"] + F -- no --> OK + F -- "no decision" --> M2["manual: target_collision → duplicate review"] +``` + The tool runs once per distinct `company_external_id`. Several EasyCLA rows share one when an org has several signing entities; each step applies to all of them. 1. Skip if the predicate fails or the log marks it complete. If the ID resolves to a live CRM account and the tool never touched it (`previous_company_external_id` empty), do step 9 only: the member-service backfill covers Membership accounts only. Otherwise resume at the first incomplete step; every step is safe to repeat. 2. Read website from the Org Service (`GET /orgs/{company_external_id}`); works for `001…` and `lf…`. No website, or a shared domain (§4) → manual. -3. Dry-run the Apex endpoint for the tranche and report it grouped by target Account. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company) and every collapse — several IDs landing on one Account — against the duplicate review (§3 step 2); rejected and `ambiguous` go to manual. -4. Call the Apex endpoint → `newId`. If the action, or the ID of a `matched` Account, differs from the approved dry run, stop; the org goes to manual. +3. Dry-run the resolution for the tranche (mapping file in M3, Apex otherwise) and report it grouped by target Account. A human approves every `matched` (domain equality can link a signed CCLA to the wrong company) and every collapse — several IDs landing on one Account — against the duplicate review (§3 Phase 1); rejected and `ambiguous` go to manual. +4. Resolve `newId`. On the Apex path, if the action, or the ID of a `matched` Account, differs from the approved dry run, stop; the org goes to manual. 5. Wait until the Org Service serves `newId` (≤ ~40 min). Create the whole tranche first, then continue. 6. Copy every ACS scope that carries the old ID (any role; org and project|org scopes) to `newId`. EasyCLA authorizes lens and Console calls on the path SFID via ACS ([`utils_user_auth_lambda.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/utils/utils_user_auth_lambda.go)), so the new scopes must exist before step 7. 7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). Rewrite the company's events as well — `event_company_sfid` and the `company_sfid_*` composite GSI keys: the Console activity log and `GET /v4/company/{id}/project/{sfid}/events` query events by SFID ([`events/repository.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/events/repository.go), `GetCompanyClaGroupEvents`), so pre-import history would disappear otherwise. 8. Delete the scopes on the old ID. -9. member-service `POST /b2b_orgs {sfid: newId}` (registers and indexes an existing account). Needs the tool's Auth0 client granted `lfx_v2_api` ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395), applied to dev, staging and prod) and `user:@clients member team:global_org_admin` in OpenFGA, written by hand per environment (team membership is hand-maintained; Eric, 2026-09-30). +9. member-service `POST /b2b_orgs {sfid: }`, which is `newId` after a rewrite (registers and indexes an existing account). Needs the tool's Auth0 client granted `lfx_v2_api` ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395), applied to dev, staging and prod) and `user:@clients member team:global_org_admin` in OpenFGA, written by hand per environment (team membership is hand-maintained; Eric, 2026-09-30). 10. Log each completed step: `old_id, new_id, action, step, timestamp`. No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. -Registration has no undo (no `DELETE /b2b_orgs/{id}`). Until the backfill filter is dropped (§6 item 5), `GET /b2b_orgs/{id}` returns 403 for a non-member Account that was never registered, so step 1 cannot classify it, and a registered non-member drops out of search on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. +Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1, and the tool on dev reports both as errors (Phase 0 tool follow-up). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. + +Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid}/cla-groups`, `GetCompanyClaGroups`) iterates every row and the follow-up calls key on each row's `company_id`. The project-scoped `GetCompanyProjectCLA` and `GetCompanyBySFID` still pick one deterministic row per SFID and log "remaining need a data merge" — Console and project paths, the #2056 remainder. ## 6. Open questions and post-M3 | # | Question | Owner | | --- | --- | --- | -| 1 | Review of the Apex endpoint (§4); field values and owner agreed 2026-09-30 | Mindy / Midhun | -| 2 | Review cadence for the Apex contribution (sandbox `rsmdev` access granted 2026-09-30) | Midhun | +| 1 | Confirm the hand-off with sales ops: mapping CSV via Data Loader for the M3 bulk (§4), Apex endpoint for the ongoing path. Review of the Apex contribution (sandbox `rsmdev` access granted 2026-09-30) proceeds in parallel, off the critical path | Michal / Mindy / Midhun | +| 2 | Monthly `lf…` batch until Option A: who signs off the mapping each month (Michal proposes sales ops confirm matches, as in Phase 1) | Michal / sales ops | | 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | -| 4 | Does ACS need the org in the Org Service when an org scope is created? (test on dev) | us | -| 5 | Remove the Member-only filter from the member-service B2B backfill; Eric: drop it entirely and ingest all Accounts (2026-09-30). Unblocks non-member registration (§5) | member-service team | +| 4 | Does ACS need the org in the Org Service when an org scope is created? Moot for the import (the tool waits for the Org Service first); test in the dev pilot anyway | us | +| 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05) | member-service team (Prabodh) | | 6 | Prod tuple for the tool's client: write it before `snapshot`/`plan` or after `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)), and finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Luis | -| 7 | Eric doubts the Account needs the legacy EasyCLA id field (§4 `LFX_Org_Id__c`); §4 uses it for idempotency on `externalKey`, so confirm before the Apex review | us | +| 7 | `LFX_Org_Id__c`: not needed for the CSV bulk load; keep it only if the Apex path wants idempotency on `externalKey` (Eric doubts it is needed) | us / Midhun | +| 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | +| 9 | Prod deploy timing of #5227 (pure reads + tool): with the next tag, before the lens CLA tab reaches prod | us | +| 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. @@ -110,8 +260,18 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-09-24** One Salesforce org; the "old platform org" is the Org Service Postgres; the sync is a DB trigger. Path A dropped. Every imported company is rewritten by the ingest tool; `lf…` companies join the ingest set; v4-creation switch moved post-M3 in favour of the sweep; no second SFID column. - **2026-09-25** PR review ([linuxfoundation/easycla#5223](https://github.com/linuxfoundation/easycla/pull/5223)): a human approves every domain match, and the endpoint returns `ambiguous` for shared domains; all ACS scopes on the old ID are copied before the rewrite and deleted after; one call per `company_external_id`, key set on create only; the tool resumes unfinished orgs; the write must return the approved dry-run result; already-live accounts are registered with member-service. - **2026-09-29** Duplicate shapes measured in the prod mirror: 3 SFIDs carry several active rows (#2056); 78 active rows (~30 groups) share a website domain or normalized name under different IDs, many of them distinct legal entities; 17 carry a shared domain (`github.com`, `nowebsite.com`) that would collapse them onto the wrong Account. The import does match + collapse only, every collapse approved by a human against a duplicate review; row merges move after the import and #2056 becomes their runbook; shared domains never match. Events are keyed by company SFID, so the rewrite covers them too. 43 active-CCLA companies have no usable `company_external_id` (#2054); they join the manual set, and the `CreateOrg` route proposed there is out. -- **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into §3 step 5: the lens already reads the auto-creating endpoint, so the pure-read change ships before the lens CLA tab (Lukasz keeps it in #5227 for time, tested on dev after merge); the shared `path-companyID` widening flagged on the PR. +- **2026-09-29** Lukasz's tool PR ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227)) reviewed against this plan: same §5 sequence; sales-ops mapping CSV first, Apex behind a flag (accepted for the tranches); sweep registers only. #2751 moved from post-M3 into the plan: the lens already reads the auto-creating endpoint, so the pure-read change ships before the lens CLA tab (Lukasz keeps it in #5227 for time, tested on dev after merge); the shared `path-companyID` widening flagged on the PR. - **2026-09-30** Midhun (Slack): new External ID field on `Account`; domain match on `Website` only; `EasyCLA` added as an `AccountSource` picklist value; created accounts owned by the integration user; sandbox `rsmdev` access granted. - **2026-09-30** Access for the tool: Auth0 grant merged and applied to dev, staging and prod ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)); `team:global_org_admin` membership is hand-maintained, so the tuple is written directly in OpenFGA, dev first (Eric). Dev member-service Salesforce password rotated ([linuxfoundation/lfx-self-serve-ops#208](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/208)). Luis's review of #5227 verified against member-service `main`: the liveness GET 403s for never-registered non-member Accounts and for 15-character IDs, and registration is one-way; dev only and member Accounts only until §6 item 5 lands, prod sequenced against member-service#120. - **2026-09-30** Eric ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6)): EasyCLA should not call `POST /b2b_orgs` as a side effect of creating an EasyCLA org; the Self-Service backend creates the B2B org and the CCLA carries the `001…` id (Option A); the backfill filter should go entirely; the legacy-id Account field may be unnecessary. A recommendation, not a mandate; the one-time import is unaffected. - **2026-09-30** Dev tuple written by hand through an OpenFGA port-forward: `user:@clients member team:global_org_admin` (client ID confirmed against the #395 apply log). OpenFGA `check` confirms `member` on the team and `auditor` on a registered org, so both tool routes are authorized in dev. Staging and prod tuples not written. Lukasz to rerun the dev dry-run; member Accounts only. +- **2026-09-30** #5227 merged (`d0601e9`) and deployed to dev; dev verification passed (reads create no rows, first signing creates one company and CCLA, contributors use the stored UUID). Ahmed ([#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)): a registered non-member Account drops out of the lens index on its next Salesforce edit because the CDC consumer, backfill and child lookups share the Membership-only `accountsSOQLWhere`; the fix is the shared rule, not the backfill; the daily sweep would mask it, not close it. Roughly half of the importable companies are affected. Follow-up tool PR [linuxfoundation/easycla#5236](https://github.com/linuxfoundation/easycla/pull/5236) (18-character normalisation). +- **2026-10-01** EasyCLA standup (Michal, Heather, Juansebastian): about a third of EasyCLA companies were created without signing intent by read paths (#2751; fixed on dev, not in prod); Salesforce cannot import with predefined IDs (David), so every migrated company gets a new ID; human review for complex cleanup cases, no AI-only decisions; plans live in MD files with diagrams, tickets point at them. Michal to produce the review CSV/sheet with Salesforce matches, the plan summary for Heather and Juansebastian, and monthly new-org statistics. This revision: §0 summary, §2.1 population matrix, §3 rewritten as phases with owners and status, §3.8 who decides what, §4 mapping CSV first and Apex for the ongoing path (reconciles the plan with the tool's runbook), diagrams in §1 and §5. +- **2026-10-01** Verified in code (`dev`): the lens list endpoint `GET /v4/company/external/{sfid}/cla-groups` (`GetCompanyClaGroups`) returns every row behind one SFID, so approved collapses hide no CCLA in the lens; `GetCompanyProjectCLA` and `GetCompanyBySFID` still pick one row (Console/project paths, #2056). Proposal for the ~1.4k companies without an active CCLA: leave until Console retirement, then delete only rows without any signature history (§6 item 8, Heather to decide). +- **2026-10-02** Michal: companies without a CCLA are not imported, their rows stay until Console retirement and only rows without any signature are deleted then. Domain discovery and duplicate detection are automated from data we already hold (approval-list domains, CLA-manager emails, the CCLA signing form in the Snowflake signature raw table, Clearbit as a second opinion) and a person confirms every match and merge; the reviewer can be Heather, Juansebastian or anyone on the EasyCLA team. Importing under the existing ID is not re-asked: Salesforce assigns Ids (Eric 2026-09-10, David 2026-10-01); instead the dead `001…` IDs were checked against the CRM mirror (`IsDeleted`, `MasterRecordId`): no trace, so they take the domain route unless sales ops hold a pre-2023 export (§6 item 10). The Org Service duplicate rows the import creates are recorded in §1 item 4 and accepted. member-service#120 merged 2026-10-01; the prod tuple waits for the cleanup run. +- **2026-10-02** History validated against the prod mirrors (§1.1): one Salesforce org since 2008; the Org Service copy was Heroku Connect and shares Ids because the Org Service used to create Accounts in Salesforce; the Org Service went standalone in mid-June 2023 (`lf…` IDs from 2023-06-18); about 58k platform-created Accounts from 2020–2023 exist only in the Org Service today. The CRM was not created by copying Org Service organizations, and the dead `001…` IDs have no merge trail in the mirrors. +- **2026-10-01** Lukasz: prod `audit` and dry-run from a laptop, read-only. Counts match §2; the live/dead `001…` split waits for the prod tuple; 20 duplicate clusters need a human decision; `to_salesforce.csv` lists the 332 `lf…` rows without a suggested Account (Phase 1 item 1). +- **2026-10-05** Michal: suggested Account matches come from the tool (live DynamoDB, CRM through member-service), not from Snowflake, which lags up to a day; tool follow-ups listed in Phase 0. +- **2026-10-05** Eric: `b2b_orgs` is a 1:1 mirror of Salesforce Accounts, not a member subset; Prabodh's assessment the same day: small change, scope all company Accounts, follow-ups in LFX One, no date (§6 item 5). Until it lands, Phase 2 registers member Accounts only. +- **2026-10-05** Tool on dev checked against the plan (review of #5223): a liveness GET on a dead or never-registered ID answers 403, because Heimdall checks `auditor` on the org, and the tool reports it as an error; #5236 did not change that. The 403 follow-up (Phase 0) now blocks the dead-`001…` rewrite tranches; the register POST reads the Account directly, so its 404 means the Account does not exist. +- **2026-10-05** Sales ops (Mindy, Tori): Data Loader confirmed for the one-time bulk; the ~960 Accounts removed in 2023 cannot be reinstated (recycle bin ≈ 1 month) and come back with new SFIDs; the 2023 files may still exist (§6 item 10). From ccaaaeb7dc60c1a5e85e0b60eae46790904e8391 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 5 Oct 2026 19:01:29 -0700 Subject: [PATCH 15/30] docs(m3): Phase 3 done-when matches the stateless register route The register route keeps no state and re-posts every live group on each run, so a repeat run reports the same registered count, not zero. Check failed=0 instead; the re-post also republishes a lost index or FGA message (raised in review of PR #5223). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index b6a708218..97fdbe7b0 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -134,7 +134,7 @@ Done when: every `matched` and every collapse has a named reviewer (Heather, Jua Prod deploy, prod dry run, then `ingest --routes register --tranche 10 --apply` → verify three groups → 100 → rest. Member Accounts first (their GET returns 200 today); non-member Accounts only after the member-service rule change (Phase 0), otherwise they drop out of the lens index on their next Salesforce edit. Registration has no API undo, so never without member-service and never for an ID the CRM does not serve. -Done when: a second run reports `registered=0`, and the three sampled companies are visible in the lens to a user who already holds a `b2b_org` grant. CLA-manager entry into the lens is the `cla_admin` relation ([m3-fga-model.md](https://github.com/linuxfoundation/easycla/pull/5210)), separate work. +Done when: a second run reports `failed=0` (the register route keeps no state and re-posts every live group, which also republishes a lost index or FGA message), and the three sampled companies are visible in the lens to a user who already holds a `b2b_org` grant. CLA-manager entry into the lens is the `cla_admin` relation ([m3-fga-model.md](https://github.com/linuxfoundation/easycla/pull/5210)), separate work. ### Phase 4 — prod `rewrite` (~1,290 groups, mapping CSV) From 5926653cac90db02af51fd9d317b98347d7a2f88 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 5 Oct 2026 23:28:44 -0700 Subject: [PATCH 16/30] docs(m3): roles note, Console /v1/me check, Prabodh owns prod cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5: note that step 6 (ACS scope copy) is the import's whole role handling; point to role-mapping-feasibility.md, #5210 and M3_ORG_LENS_API.md for how CLA manager, designee and signatory work in M3; the signatory lens entry stays open. - Phase 4: sampled CLA managers must still see the company in the Corporate Console, which finds it through the Org Service profile (GET /v1/me), not through role scopes. - §1.1 item 4: starting a CCLA creates the EasyCLA row; reads only do so until #5227 is deployed (per copilot review). - Phase 0 / §6 item 6: Prabodh coordinates the prod tuple and the global-org-admin cleanup while Luis is away; log entry for 2026-10-05. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 97fdbe7b0..9ece63d49 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -56,7 +56,7 @@ What follows: 1. `sfid_b2b == sfid` for every CRM account because the trigger copies the Id. Latency CRM → Org Service ≈ 0–40 min (Stitch interval + trigger). 2. **Importing into Salesforce while keeping an existing ID is impossible.** Salesforce assigns Ids; nothing in LFX ever pushed one in (confirmed again with David, standup 2026-10-01). "Path A" in #2750 is dropped. 3. **Every company we create or match an account for gets a new `company_external_id`.** Only companies whose ID already resolves to a live CRM account stay untouched. -4. Creating an Account for a rewritten company adds a second Org Service row for the same organization: the trigger inserts the new `001…` and the old `lf…` or dead `001…` row stays (the domain linker is not scheduled, nothing deletes it). About 1,290 such pairs after the import, next to the 962 dead `001…` rows the Org Service already serves today. EasyCLA and the Corporate Console follow `company_external_id`, so the rewritten company itself is unaffected. The exposures are the two lookups that search the Org Service by name: the Corporate Console's create-company lookup (`GET /v4/company/lookup`) and the Contributor Console's company search (`GET /v3/organization/search`). Both can return the old row, listed without a CCLA; a user who picks it creates a second EasyCLA company with a dead ID (the company-by-external-ID endpoints auto-create from the Org Service record), and a contributor's CLA-manager request then reaches nobody. That company then shows up as a new group for the monthly batch (§3 Phase 6) and lands on the same Account with a `collapse` decision. Accepted for the parallel window, checked on samples in Phase 4; deleting or renaming orphan rows in the retiring Org Service is not planned. Fallback if ghosts appear: EasyCLA hides the IDs recorded in `previous_company_external_id` from these lookups and redirects the auto-create to the new company. +4. Creating an Account for a rewritten company adds a second Org Service row for the same organization: the trigger inserts the new `001…` and the old `lf…` or dead `001…` row stays (the domain linker is not scheduled, nothing deletes it). About 1,290 such pairs after the import, next to the 962 dead `001…` rows the Org Service already serves today. EasyCLA and the Corporate Console follow `company_external_id`, so the rewritten company itself is unaffected. The exposures are the two lookups that search the Org Service by name: the Corporate Console's create-company lookup (`GET /v4/company/lookup`) and the Contributor Console's company search (`GET /v3/organization/search`). Both can return the old row, listed without a CCLA; a user who picks it creates a second EasyCLA company with a dead ID (starting a CCLA creates the EasyCLA row from the Org Service record; until #5227 is deployed, so does any company-by-external-ID read), and a contributor's CLA-manager request then reaches nobody. That company then shows up as a new group for the monthly batch (§3 Phase 6) and lands on the same Account with a `collapse` decision. Accepted for the parallel window, checked on samples in Phase 4; deleting or renaming orphan rows in the retiring Org Service is not planned. Fallback if ghosts appear: EasyCLA hides the IDs recorded in `previous_company_external_id` from these lookups and redirects the auto-create to the new company. ### 1.1 How we got here (checked against the prod Snowflake mirrors, 2026-10-02) @@ -113,7 +113,7 @@ Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list | Tool follow-up: normalise IDs to 18 characters before the liveness GET; report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite). Blocks the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | open; not in [linuxfoundation/easycla#5236](https://github.com/linuxfoundation/easycla/pull/5236) (merged 2026-10-01) | | Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service | Lukasz | proposed 2026-10-05 | | member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every Salesforce Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05). Dev check: register a non-member Account, edit one field in Salesforce, confirm its `b2b_org` document survives | member-service team (Prabodh) | direction confirmed 2026-10-05; [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) 2026-10-05: one-file change plus a switch, about 10,300 more Accounts, excludes sanctions-screening, project and personal Accounts, follow-ups on family access, LFX One slug ties and a gradual rollout; no date; gates non-member registration in prod | -| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Luis | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple and deploy open | +| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Prabodh | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple and deploy open | | Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | hand-off to agree; Apex not on the M3 critical path | ### Phase 1 — prod audit and review pack (now, read-only) @@ -140,7 +140,7 @@ Done when: a second run reports `failed=0` (the register route keeps no state an Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. -Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID, and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. +Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID and still sees the company in the Corporate Console (it finds the user's company through the Org Service profile, `GET /v1/me`, not through role scopes), and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. ### Phase 5 — manual set (64 companies) @@ -229,6 +229,8 @@ The tool runs once per distinct `company_external_id`. Several EasyCLA rows shar No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. +Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); CLA managers get `cla_admin` on the `b2b_org` for lens entry, designees and signatories get no relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)); the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). + Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1, and the tool on dev reports both as errors (Phase 0 tool follow-up). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid}/cla-groups`, `GetCompanyClaGroups`) iterates every row and the follow-up calls key on each row's `company_id`. The project-scoped `GetCompanyProjectCLA` and `GetCompanyBySFID` still pick one deterministic row per SFID and log "remaining need a data merge" — Console and project paths, the #2056 remainder. @@ -242,7 +244,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | | 4 | Does ACS need the org in the Org Service when an org scope is created? Moot for the import (the tool waits for the Org Service first); test in the dev pilot anyway | us | | 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05) | member-service team (Prabodh) | -| 6 | Prod tuple for the tool's client: write it before `snapshot`/`plan` or after `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)), and finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Luis | +| 6 | Prod tuple for the tool's client: write it before `snapshot`/`plan` or after `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)), and finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Prabodh | | 7 | `LFX_Org_Id__c`: not needed for the CSV bulk load; keep it only if the Apex path wants idempotency on `externalKey` (Eric doubts it is needed) | us / Midhun | | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy timing of #5227 (pure reads + tool): with the next tag, before the lens CLA tab reaches prod | us | @@ -275,3 +277,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-05** Eric: `b2b_orgs` is a 1:1 mirror of Salesforce Accounts, not a member subset; Prabodh's assessment the same day: small change, scope all company Accounts, follow-ups in LFX One, no date (§6 item 5). Until it lands, Phase 2 registers member Accounts only. - **2026-10-05** Tool on dev checked against the plan (review of #5223): a liveness GET on a dead or never-registered ID answers 403, because Heimdall checks `auditor` on the org, and the tool reports it as an error; #5236 did not change that. The 403 follow-up (Phase 0) now blocks the dead-`001…` rewrite tranches; the register POST reads the Account directly, so its 404 means the Account does not exist. - **2026-10-05** Sales ops (Mindy, Tori): Data Loader confirmed for the one-time bulk; the ~960 Accounts removed in 2023 cannot be reinstated (recycle bin ≈ 1 month) and come back with new SFIDs; the 2023 files may still exist (§6 item 10). +- **2026-10-05** Luis: no prod cleanup of the global-org-admin migration run yet; away the week of 2026-10-06, Prabodh coordinates the prod tuple and cleanup (§6 item 6). SSM parameters confirmed applied in dev and prod; prod member-service chart 0.10.24 already checks `team:global_org_admin`. Roles note added to §5; Corporate Console `/v1/me` check added to Phase 4. From 78bd3201db10801d3f71fe44a26711ae68552502 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 6 Oct 2026 09:12:22 -0700 Subject: [PATCH 17/30] docs(m3): record Prabodh's go-ahead for the prod tuple Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 9ece63d49..da2ae4f54 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -113,7 +113,7 @@ Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list | Tool follow-up: normalise IDs to 18 characters before the liveness GET; report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite). Blocks the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | open; not in [linuxfoundation/easycla#5236](https://github.com/linuxfoundation/easycla/pull/5236) (merged 2026-10-01) | | Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service | Lukasz | proposed 2026-10-05 | | member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every Salesforce Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05). Dev check: register a non-member Account, edit one field in Salesforce, confirm its `b2b_org` document survives | member-service team (Prabodh) | direction confirmed 2026-10-05; [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) 2026-10-05: one-file change plus a switch, about 10,300 more Accounts, excludes sanctions-screening, project and personal Accounts, follow-ups on family access, LFX One slug ties and a gradual rollout; no date; gates non-member registration in prod | -| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Prabodh | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple and deploy open | +| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Prabodh | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple: Prabodh agreed 2026-10-06 that we write it now by OpenFGA port-forward, as in dev; deploy open | | Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | hand-off to agree; Apex not on the M3 critical path | ### Phase 1 — prod audit and review pack (now, read-only) @@ -278,3 +278,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-05** Tool on dev checked against the plan (review of #5223): a liveness GET on a dead or never-registered ID answers 403, because Heimdall checks `auditor` on the org, and the tool reports it as an error; #5236 did not change that. The 403 follow-up (Phase 0) now blocks the dead-`001…` rewrite tranches; the register POST reads the Account directly, so its 404 means the Account does not exist. - **2026-10-05** Sales ops (Mindy, Tori): Data Loader confirmed for the one-time bulk; the ~960 Accounts removed in 2023 cannot be reinstated (recycle bin ≈ 1 month) and come back with new SFIDs; the 2023 files may still exist (§6 item 10). - **2026-10-05** Luis: no prod cleanup of the global-org-admin migration run yet; away the week of 2026-10-06, Prabodh coordinates the prod tuple and cleanup (§6 item 6). SSM parameters confirmed applied in dev and prod; prod member-service chart 0.10.24 already checks `team:global_org_admin`. Roles note added to §5; Corporate Console `/v1/me` check added to Phase 4. +- **2026-10-06** Prabodh: write the prod tuple now, through an OpenFGA port-forward as in dev; cleanup deletes only the old team's tuples; no fga-sync cache bump needed, Heimdall checks member-service routes against OpenFGA directly and its prod check cache expires after 10 s; cleanup date "soon". Lukasz runs the prod audit and ingest dry-run (no writes); first register tranche after the tuple. Member Accounts do not wait for the cleanup, non-member Accounts do (§6 item 6). From b97cf39143bfacafe45ba978f38c085b926bea89 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 6 Oct 2026 10:01:04 -0700 Subject: [PATCH 18/30] docs(m3): record #5239, #5240, the 2026-10-06 prod dry run, Jordan for the tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: 403 and dry-run follow-ups done in #5239; prod deploy goes through the release PR #5240 (dev -> main, 2026-10-07); the prod tuple needs cluster access, so the request goes to Jordan Evans. Phase 1: 2026-10-06 re-run counts and the new audit.csv columns; what the tool matches today vs. what it does not. Prod order-of-operations diagram in §3. Copilot's overview items: §2 counts reconciled against the live audit, step 10 lists the real state-file fields, `leave` marked as an exception to the §0 goal, 15/18-char handling stated as the tool does it, Phase 1 item 2 no longer claims enrichment the tool does not do. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index da2ae4f54..fcf76b688 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -12,10 +12,10 @@ This file is the single place for the plan and its decisions. Tickets carry poin ## 0. Summary - **Goal**: every EasyCLA company with an active CCLA points at a live Salesforce Account (`001…`) and is registered in member-service, so it appears in the Self Serve Organization lens. Internal `company_id`s never change; the import deletes or merges no EasyCLA row. -- **Tool**: Lukasz's `org-import` ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227), merged, on dev). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. +- **Tool**: Lukasz's `org-import` ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227) and [#5239](https://github.com/linuxfoundation/easycla/pull/5239), on dev; prod with the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240)). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. - **Order** (§3): prod audit and review pack → dev pilot → prod `register` (member Accounts first) → prod `rewrite` tranches 10 / 100 / rest → manual set → daily sweep. Duplicate companies collapse onto one Account inside the rewrite tranches, after a human decision; row merges stay post-import. - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Blocked today**: prod access for the tool (tuple sequenced after [lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)); the member-service qualifying rule for non-member Accounts (§6 item 5, gates non-member registration in prod); prod deploy of #5227; the tool's liveness-403 follow-up (blocks the dead-`001…` rewrites, §3 Phase 0); sales-ops time for the mapping. +- **Blocked today**: the prod tuple for the tool (Prabodh cleared it 2026-10-06; writing it needs prod cluster access, request to Jordan Evans); the member-service qualifying rule for non-member Accounts (§6 item 5, gates non-member registration in prod); prod deploy ([#5240](https://github.com/linuxfoundation/easycla/pull/5240), planned 2026-10-07); the Phase 1 review sheet; sales-ops time for the mapping. - **Not in M3**: switching v4 org creation to Salesforce (Eric's Option A, post-M3); row merges (#2056); deleting the ~1.4k companies without an active CCLA. ## 1. One Salesforce org, and where the IDs come from @@ -56,7 +56,7 @@ What follows: 1. `sfid_b2b == sfid` for every CRM account because the trigger copies the Id. Latency CRM → Org Service ≈ 0–40 min (Stitch interval + trigger). 2. **Importing into Salesforce while keeping an existing ID is impossible.** Salesforce assigns Ids; nothing in LFX ever pushed one in (confirmed again with David, standup 2026-10-01). "Path A" in #2750 is dropped. 3. **Every company we create or match an account for gets a new `company_external_id`.** Only companies whose ID already resolves to a live CRM account stay untouched. -4. Creating an Account for a rewritten company adds a second Org Service row for the same organization: the trigger inserts the new `001…` and the old `lf…` or dead `001…` row stays (the domain linker is not scheduled, nothing deletes it). About 1,290 such pairs after the import, next to the 962 dead `001…` rows the Org Service already serves today. EasyCLA and the Corporate Console follow `company_external_id`, so the rewritten company itself is unaffected. The exposures are the two lookups that search the Org Service by name: the Corporate Console's create-company lookup (`GET /v4/company/lookup`) and the Contributor Console's company search (`GET /v3/organization/search`). Both can return the old row, listed without a CCLA; a user who picks it creates a second EasyCLA company with a dead ID (starting a CCLA creates the EasyCLA row from the Org Service record; until #5227 is deployed, so does any company-by-external-ID read), and a contributor's CLA-manager request then reaches nobody. That company then shows up as a new group for the monthly batch (§3 Phase 6) and lands on the same Account with a `collapse` decision. Accepted for the parallel window, checked on samples in Phase 4; deleting or renaming orphan rows in the retiring Org Service is not planned. Fallback if ghosts appear: EasyCLA hides the IDs recorded in `previous_company_external_id` from these lookups and redirects the auto-create to the new company. +4. Creating an Account for a rewritten company adds a second Org Service row for the same organization: the trigger inserts the new `001…` and the old `lf…` or dead `001…` row stays (the domain linker is not scheduled, nothing deletes it). About 1,290 such pairs after the import, next to the 962 dead `001…` rows the Org Service already serves today. EasyCLA and the Corporate Console follow `company_external_id`, so the rewritten company itself is unaffected. The exposures are the two lookups that search the Org Service by name: the Corporate Console's create-company lookup (`GET /v4/company/lookup`) and the Contributor Console's company search (`GET /v3/organization/search`). Both can return the old row, listed without a CCLA; a user who picks it creates a second EasyCLA company with a dead ID (starting a CCLA creates the EasyCLA row from the Org Service record; until #5240 reaches prod, so does any company-by-external-ID read), and a contributor's CLA-manager request then reaches nobody. That company then shows up as a new group for the monthly batch (§3 Phase 6) and lands on the same Account with a `collapse` decision. Accepted for the parallel window, checked on samples in Phase 4; deleting or renaming orphan rows in the retiring Org Service is not planned. Fallback if ghosts appear: EasyCLA hides the IDs recorded in `previous_company_external_id` from these lookups and redirects the auto-create to the new company. ### 1.1 How we got here (checked against the prod Snowflake mirrors, 2026-10-02) @@ -80,11 +80,11 @@ Two things this rules out. The CRM was **not** created recently by copying a sub | Resolves nowhere | 18 | Manual | | No usable `company_external_id` (empty or malformed; 2026-09-29) | 43 | Manual: no Org Service row, so no website. 5 have an exact CRM name match; 28 have no ECLA. Listed in [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) | -The tool's prod audit (Lukasz, 2026-10-01, DynamoDB) gives the same picture: 1,954 distinct `001…` IDs across 1,958 companies, 332 `lf…`, 43 manual, 1,390 without an active CCLA. +The tool's prod audit (Lukasz, 2026-10-01, live DynamoDB) gives the same picture with a small drift: 1,958 `001…` companies (1,954 distinct IDs) against 1,975 in the table, 332 `lf…` against 331, 43 manual, 1,390 without an active CCLA; the mirror is from 2026-09-18 and CCLAs are signed and invalidated in between. The 2026-10-06 dry run counts 2,339 eligible groups: 1,963 `001…`, 333 `lf…`, 43 manual. The audit, not this table, sizes the tranches. Ingest predicate: **active CCLA** (`ccla` signature, `signature_reference_type = 'company'`, signed and approved) on any row of the `company_external_id` group, evaluated at run time; an eligible group is rewritten whole. ID shape decides the route, not eligibility. Companies without an active CCLA stay in EasyCLA untouched; their disposition before Console retirement is an exit criterion of #2750. Growth: ~16 new CCLA companies per month. -The excluded set is large because EasyCLA minted rows without signing intent: 1,380 of 3,689 prod companies (37%) have no active CCLA and 1,237 have no CCLA record at all (#2749, 2026-09-18). Most of those came from read paths — company search with signing entities, `GET /company/external/{sfid}`, the v4 lookups by SFID — that persisted a row for every organization a user merely looked at (#2751). The pure-read fix is on dev with #5227; prod gets it with the next tag deploy (§3, Phase 0). Until then prod keeps growing that set. +The excluded set is large because EasyCLA minted rows without signing intent: 1,380 of 3,689 prod companies (37%) have no active CCLA and 1,237 have no CCLA record at all (#2749, 2026-09-18). Most of those came from read paths — company search with signing entities, `GET /company/external/{sfid}`, the v4 lookups by SFID — that persisted a row for every organization a user merely looked at (#2751). The pure-read fix is on dev with #5227; prod gets it with #5240 (§3, Phase 0). Until then prod keeps growing that set. ### 2.1 What happens to each company @@ -102,7 +102,24 @@ The excluded set is large because EasyCLA minted rows without signing intent: 1, ## 3. Plan -Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-05. +Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-06. + +Prod order of operations, who waits for whom: + +```mermaid +flowchart LR + T["prod tuple
(Jordan, on request)"] --> DR["prod dry run with liveness
(Lukasz)"] + D["deploy #5240
(Lukasz, 2026-10-07)"] --> R + DR --> R["Phase 3: register
member Accounts"] + DR --> S["Phase 1: review sheet
(Heather, Juansebastian)"] + S --> SO["sales ops: approve matches,
create Accounts → map.csv"] + SO --> RW["Phase 4: rewrite
10 / 100 / rest"] + RW --> M["Phase 5: manual 64"] + C["member-service cleanup
+ 1:1 Account rule (Prabodh)"] --> NM["register non-member
Accounts"] + R --> NM + NM --> SW["Phase 6: daily sweep"] + M --> SW +``` ### Phase 0 — prerequisites (in progress) @@ -110,17 +127,17 @@ Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list | --- | --- | --- | | Tool merged and deployed to dev; pure-read company endpoints (#2751) verified on dev | Lukasz | done (`d0601e9`, 2026-09-30) | | Dev access: Auth0 grant ([auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)), `team:global_org_admin` tuple, member-service Salesforce password | Michal / platform | done 2026-09-30 | -| Tool follow-up: normalise IDs to 18 characters before the liveness GET; report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite). Blocks the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | open; not in [linuxfoundation/easycla#5236](https://github.com/linuxfoundation/easycla/pull/5236) (merged 2026-10-01) | -| Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service | Lukasz | proposed 2026-10-05 | +| Tool follow-up: report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite); the 15- and 18-character forms of one ID count as one Account. Blocked the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | done ([linuxfoundation/easycla#5239](https://github.com/linuxfoundation/easycla/pull/5239), merged 2026-10-06); when every Account answers 403 the tool stops classifying (all-403 guard) instead of guessing, which is what the 2026-10-06 prod dry run shows until the tuple exists | +| Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service; ACS roles per old ID | Lukasz | done (#5239, 2026-10-06): `audit.csv` gains `suggested_account` (up to three Accounts from member-service search by domain and name) and `acs_roles` (`role=count`); Org Service domains stay unused, EasyCLA only reads them for sanctions screening and never fills them | | member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every Salesforce Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05). Dev check: register a non-member Account, edit one field in Salesforce, confirm its `b2b_org` document survives | member-service team (Prabodh) | direction confirmed 2026-10-05; [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) 2026-10-05: one-file change plus a switch, about 10,300 more Accounts, excludes sanctions-screening, project and personal Accounts, follow-ups on family access, LFX One slug ties and a gradual rollout; no date; gates non-member registration in prod | -| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple after the member-service#120 cleanup; prod deploy of #5227 by tag (`deploy-prod.yml`) | Michal / Prabodh | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple: Prabodh agreed 2026-10-06 that we write it now by OpenFGA port-forward, as in dev; deploy open | +| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple `user:@clients member team:global_org_admin`; prod deploy through the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) | Michal / Prabodh / Lukasz | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple: Prabodh agreed 2026-10-06 to write it now (OpenFGA port-forward, as in dev); that needs prod cluster access, so the request goes to Jordan Evans in #lfx-architecture, who wrote `team:crowdfunding_approvers` the same way; deploy: #5240 open, dev E2E 2026-10-07, then merge and deploy | | Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | hand-off to agree; Apex not on the M3 critical path | ### Phase 1 — prod audit and review pack (now, read-only) -1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. -2. Domains and candidate matches, automated (read-only). The tool knows only the Org Service website; for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching asked of the tool (Phase 0). -3. One review sheet for Heather, Juansebastian and sales ops, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. +1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. **Re-run 2026-10-06** with #5239 ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791286894816259)), no writes: 3,735 company rows audited; dry run `eligible=2339`: 333 pending rewrite, 43 manual, 1,963 `001…` groups held by the all-403 guard until the prod tuple exists; `audit.csv` now carries `suggested_account` and `acs_roles`; reports emailed (`audit.csv`, `unresolvable.csv`, `possible_duplicates.csv`, `run.log`). This is the review material for item 3. +2. Domains and candidate matches, automated (read-only). In the tool since #5239: website → registrable domain, shared-domain list, member-service search by domain and name → `suggested_account`. Not in the tool, and only if the review leaves too many rows unmatched (Lukasz takes the CCLA domain approval lists next): for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching (Phase 0, done in #5239). +3. One review sheet (Michal builds it from the 2026-10-06 reports) for Heather, Juansebastian and sales ops, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. 4. Monthly statistics for the standup: companies created per month, split by "has a CCLA" / "none", so the premature-row rate before and after the #2751 fix is visible. 5. Outputs: `map.csv` (§4), `decisions.csv` (runbook §4.1), the shared-domain list, the manual-set decisions recorded in #2749. @@ -132,7 +149,7 @@ Done when: every `matched` and every collapse has a named reviewer (Heather, Jua ### Phase 3 — prod `register` (995 groups, no Salesforce writes) -Prod deploy, prod dry run, then `ingest --routes register --tranche 10 --apply` → verify three groups → 100 → rest. Member Accounts first (their GET returns 200 today); non-member Accounts only after the member-service rule change (Phase 0), otherwise they drop out of the lens index on their next Salesforce edit. Registration has no API undo, so never without member-service and never for an ID the CRM does not serve. +Prod deploy (#5240), prod tuple, prod dry run with liveness, then `ingest --routes register --tranche 10 --apply` → verify three groups → 100 → rest. Member Accounts first (their GET returns 200 today); non-member Accounts only after the member-service rule change (Phase 0), otherwise they drop out of the lens index on their next Salesforce edit. Registration has no API undo, so never without member-service and never for an ID the CRM does not serve. Done when: a second run reports `failed=0` (the register route keeps no state and re-posts every live group, which also republishes a lost index or FGA message), and the three sampled companies are visible in the lens to a user who already holds a `b2b_org` grant. CLA-manager entry into the lens is the `cla_admin` relation ([m3-fga-model.md](https://github.com/linuxfoundation/easycla/pull/5210)), separate work. @@ -144,7 +161,7 @@ Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, ### Phase 5 — manual set (64 companies) -Per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749. Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. +Per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749 (each *leave* is a recorded exception to the §0 goal, not a route). Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. ### Phase 6 — steady state until Option A @@ -163,7 +180,7 @@ Per row from the review sheet: a live Account chosen or created by sales ops → | Step | Decided by | Tool's part | | --- | --- | --- | | An ID is live in the CRM | fact (member-service GET) | classifies, registers | -| A dead or `lf…` company equals an existing Account | a person, per match (`approved=true`, reviewer named) | suggests an Account (follow-up, Phase 0); refuses unapproved matches | +| A dead or `lf…` company equals an existing Account | a person, per match (`approved=true`, reviewer named) | suggests up to three Accounts (`suggested_account`, #5239); refuses unapproved matches | | A new Account is created | sales ops, from the reviewed to-create list | none in M3 (Apex path off) | | Several IDs are one organization | a named reviewer, per group (`collapse` / `distinct`) | refuses collisions without a decision | | Unresolvable row | a person, per row | lists it with a suggested action | @@ -225,13 +242,13 @@ The tool runs once per distinct `company_external_id`. Several EasyCLA rows shar 7. Set `company_external_id = newId` on every row; keep the old value in `previous_company_external_id` (plain attribute, nothing reads it). Rewrite the company's events as well — `event_company_sfid` and the `company_sfid_*` composite GSI keys: the Console activity log and `GET /v4/company/{id}/project/{sfid}/events` query events by SFID ([`events/repository.go`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/events/repository.go), `GetCompanyClaGroupEvents`), so pre-import history would disappear otherwise. 8. Delete the scopes on the old ID. 9. member-service `POST /b2b_orgs {sfid: }`, which is `newId` after a rewrite (registers and indexes an existing account). Needs the tool's Auth0 client granted `lfx_v2_api` ([linuxfoundation/auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395), applied to dev, staging and prod) and `user:@clients member team:global_org_admin` in OpenFGA, written by hand per environment (team membership is hand-maintained; Eric, 2026-09-30). -10. Log each completed step: `old_id, new_id, action, step, timestamp`. +10. Journal each completed step (the tool's state file): `old_id, key, new_id, company_ids, step, ts, status, err`. No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); CLA managers get `cla_admin` on the `b2b_org` for lens entry, designees and signatories get no relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)); the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). -Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1, and the tool on dev reports both as errors (Phase 0 tool follow-up). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. +Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1; the tool reports both as `unregistered` and, when every Account answers 403, stops classifying (#5239). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid}/cla-groups`, `GetCompanyClaGroups`) iterates every row and the follow-up calls key on each row's `company_id`. The project-scoped `GetCompanyProjectCLA` and `GetCompanyBySFID` still pick one deterministic row per SFID and log "remaining need a data merge" — Console and project paths, the #2056 remainder. @@ -244,10 +261,10 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | | 4 | Does ACS need the org in the Org Service when an org scope is created? Moot for the import (the tool waits for the Org Service first); test in the dev pilot anyway | us | | 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05) | member-service team (Prabodh) | -| 6 | Prod tuple for the tool's client: write it before `snapshot`/`plan` or after `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)), and finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Prabodh | +| 6 | Prod tuple for the tool's client: Prabodh (2026-10-06) says write it now, the `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)) deletes only the old team's tuples; still to confirm that no prod `plan` exists yet (membership edits between `plan` and `cleanup` make later phases exit 4). Jordan Evans writes it (prod cluster access). Finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Prabodh / Jordan | | 7 | `LFX_Org_Id__c`: not needed for the CSV bulk load; keep it only if the Apex path wants idempotency on `externalKey` (Eric doubts it is needed) | us / Midhun | | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | -| 9 | Prod deploy timing of #5227 (pure reads + tool): with the next tag, before the lens CLA tab reaches prod | us | +| 9 | Prod deploy of the M3 backend (pure reads + tool): release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240), dev → main, same tree as dev; approve 2026-10-06, Lukasz merges and deploys 2026-10-07 after the dev E2E | Lukasz / us | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. @@ -279,3 +296,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-05** Sales ops (Mindy, Tori): Data Loader confirmed for the one-time bulk; the ~960 Accounts removed in 2023 cannot be reinstated (recycle bin ≈ 1 month) and come back with new SFIDs; the 2023 files may still exist (§6 item 10). - **2026-10-05** Luis: no prod cleanup of the global-org-admin migration run yet; away the week of 2026-10-06, Prabodh coordinates the prod tuple and cleanup (§6 item 6). SSM parameters confirmed applied in dev and prod; prod member-service chart 0.10.24 already checks `team:global_org_admin`. Roles note added to §5; Corporate Console `/v1/me` check added to Phase 4. - **2026-10-06** Prabodh: write the prod tuple now, through an OpenFGA port-forward as in dev; cleanup deletes only the old team's tuples; no fga-sync cache bump needed, Heimdall checks member-service routes against OpenFGA directly and its prod check cache expires after 10 s; cleanup date "soon". Lukasz runs the prod audit and ingest dry-run (no writes); first register tranche after the tuple. Member Accounts do not wait for the cleanup, non-member Accounts do (§6 item 6). +- **2026-10-06** Lukasz: [#5239](https://github.com/linuxfoundation/easycla/pull/5239) merged and on dev (all four asks of 2026-10-05; Org Service domains stay unused); prod audit and ingest dry run re-run with it, no writes: 2,339 eligible groups, 333 pending, 43 manual, 1,963 `001…` groups held by the all-403 guard; `audit.csv` carries `suggested_account` and `acs_roles`; reports emailed. Release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) up, dev E2E 2026-10-07, then merge and deploy. Prod client ID is SSM `cla-auth0-platform-client-id-prod`. Writing the tuple needs prod cluster access: request to Jordan Evans in #lfx-architecture, the way Efren got `team:crowdfunding_approvers` ([lfx-crowdfunding#303](https://github.com/linuxfoundation/lfx-crowdfunding/issues/303)). Phase 1 review sheet to be built from the 2026-10-06 reports for Heather and Juansebastian. From 0a972fe7309ceeba226717b1ed13ca48d60aff38 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 8 Oct 2026 11:04:11 -0700 Subject: [PATCH 19/30] docs(m3): stage the org import around members first Status 2026-10-08. M3 = member companies (825 groups already in the lens) manage CCLAs there; M3.1 = every live Account after lfx-self-serve#3401; M3.2 = Self Serve create flow, then Console retirement. Phase 4 split into the M3 member tranche (4a, no Salesforce writes) and the post-M3.1 tranches (4b); Phase 0 rows updated with the prod tuple, v2.10.0-v2.10.3, acs-cli v0.2.11 and the #3401 ticket; the 2026-10-06 prod dry run with liveness recorded. New open items: the CLA-manager lens entry (cla_admin, #5210) on the M3 critical path and the undecided members-only CCLA idea. Decision log for 2026-10-07/08. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index fcf76b688..a4ef778fc 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -3,7 +3,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3: getting EasyCLA companies into Salesforce -**Status**: draft 2026-10-05 · **Owner**: Michal (engineering) +**Status**: draft 2026-10-08 · **Owner**: Michal (engineering) **Tickets**: [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) (story) · [#2749](https://github.com/linuxfoundation/lfx-self-serve/issues/2749) (cleanup) · [#2751](https://github.com/linuxfoundation/lfx-self-serve/issues/2751) (orgs created without signing intent) · [#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) (unreachable companies) · [#3085](https://github.com/linuxfoundation/lfx-self-serve/issues/3085) (duplicate review) · [#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) (duplicate companies; post-import merges) · epic [#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) **Related**: [m3-org-visibility.md](https://github.com/linuxfoundation/easycla/pull/5210) (earlier analysis, superseded here for the import) · Eric's [Consolidate-B2B-Backend proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend) · sales-ops thread [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) · tool runbook [`cmd/org_import/README.md`](https://github.com/linuxfoundation/easycla/blob/dev/cla-backend-go/cmd/org_import/README.md) · manual cleanup runbook [`m3-org-cleanup.md`](https://github.com/linuxfoundation/easycla/blob/dev/docs/easycla-ss-migration/m3-org-cleanup.md) @@ -12,11 +12,12 @@ This file is the single place for the plan and its decisions. Tickets carry poin ## 0. Summary - **Goal**: every EasyCLA company with an active CCLA points at a live Salesforce Account (`001…`) and is registered in member-service, so it appears in the Self Serve Organization lens. Internal `company_id`s never change; the import deletes or merges no EasyCLA row. -- **Tool**: Lukasz's `org-import` ([linuxfoundation/easycla#5227](https://github.com/linuxfoundation/easycla/pull/5227) and [#5239](https://github.com/linuxfoundation/easycla/pull/5239), on dev; prod with the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240)). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. -- **Order** (§3): prod audit and review pack → dev pilot → prod `register` (member Accounts first) → prod `rewrite` tranches 10 / 100 / rest → manual set → daily sweep. Duplicate companies collapse onto one Account inside the rewrite tranches, after a human decision; row merges stay post-import. +- **Staged** (agreed with Heather on the 2026-10-08 call; §3): **M3** (target 2026-10-16/17) = member companies, the 825 groups whose Account is already in the lens, manage their CCLAs there; every other company keeps the Corporate Console, unchanged. **M3.1** = every company whose ID is a live Account appears in the lens by itself once member-service drops the membership-only filter ([lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401)); no EasyCLA release. **M3.2** = new companies are created from Self Serve (Eric's Option A), then the Console is retired. +- **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. +- **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Blocked today**: the prod tuple for the tool (Prabodh cleared it 2026-10-06; writing it needs prod cluster access, request to Jordan Evans); the member-service qualifying rule for non-member Accounts (§6 item 5, gates non-member registration in prod); prod deploy ([#5240](https://github.com/linuxfoundation/easycla/pull/5240), planned 2026-10-07); the Phase 1 review sheet; sales-ops time for the mapping. -- **Not in M3**: switching v4 org creation to Salesforce (Eric's Option A, post-M3); row merges (#2056); deleting the ~1.4k companies without an active CCLA. +- **Open for M3**: CLA-manager entry into the lens. Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization, so the `cla_admin` relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) is on the M3 critical path and nothing is built yet (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -102,23 +103,37 @@ The excluded set is large because EasyCLA minted rows without signing intent: 1, ## 3. Plan -Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-06. +Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-08. -Prod order of operations, who waits for whom: +Three stages (2026-10-08). Phase numbers are unchanged; the table says which stage each phase belongs to. + +| Stage | Who is in the lens | Work | Status | +| --- | --- | --- | --- | +| **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (`cla_admin`, §6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet in progress (Lukasz); `cla_admin` not started | +| **M3.1** | Every company whose ID is a live Account (995 by the §2 count: the 825 members plus the live non-member Accounts) | [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (Prabodh, from the week of 2026-10-12) registers every company Account by itself; then Phases 4b and 5 for the groups that need a new or matched Account (sales ops, Data Loader) | #3401 open; sales-ops hand-off on hold until then | +| **M3.2** | New companies, created from Self Serve | Option A (§6 item 3): Self Serve creates the Account through member-service, then the EasyCLA company and the CCLA; EasyCLA stops minting `lf…` IDs; the Console is retired | not started | + +Order of operations, who waits for whom: ```mermaid flowchart LR - T["prod tuple
(Jordan, on request)"] --> DR["prod dry run with liveness
(Lukasz)"] - D["deploy #5240
(Lukasz, 2026-10-07)"] --> R - DR --> R["Phase 3: register
member Accounts"] - DR --> S["Phase 1: review sheet
(Heather, Juansebastian)"] - S --> SO["sales ops: approve matches,
create Accounts → map.csv"] - SO --> RW["Phase 4: rewrite
10 / 100 / rest"] - RW --> M["Phase 5: manual 64"] - C["member-service cleanup
+ 1:1 Account rule (Prabodh)"] --> NM["register non-member
Accounts"] - R --> NM - NM --> SW["Phase 6: daily sweep"] - M --> SW + subgraph M3["M3 — members"] + SH["member duplicate sheet
(Lukasz)"] --> RV["review: collapse / distinct
(Heather, Juansebastian)"] + RV --> RW1["Phase 4a: rewrite onto
the member Account"] + CA["cla_admin: model, tuples,
Self Serve gate (#5210)"] --> CM["CLA managers manage
CCLAs in the lens"] + RW1 --> CM + end + subgraph M31["M3.1 — every Account"] + F["#3401: member-service drops
the membership filter (Prabodh)"] --> ALL["every company Account
registered by the backfill"] + ALL --> SO["sales ops: approve matches,
create Accounts → map.csv"] + SO --> RW2["Phase 4b: rewrite
10 / 100 / rest"] --> MAN["Phase 5: manual"] + MAN --> SW["Phase 6: daily sweep"] + end + subgraph M32["M3.2"] + CF["Self Serve create flow
(Option A)"] --> RET["Console retirement"] + end + CM --> F + SW --> CF ``` ### Phase 0 — prerequisites (in progress) @@ -129,15 +144,15 @@ flowchart LR | Dev access: Auth0 grant ([auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)), `team:global_org_admin` tuple, member-service Salesforce password | Michal / platform | done 2026-09-30 | | Tool follow-up: report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite); the 15- and 18-character forms of one ID count as one Account. Blocked the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | done ([linuxfoundation/easycla#5239](https://github.com/linuxfoundation/easycla/pull/5239), merged 2026-10-06); when every Account answers 403 the tool stops classifying (all-403 guard) instead of guessing, which is what the 2026-10-06 prod dry run shows until the tuple exists | | Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service; ACS roles per old ID | Lukasz | done (#5239, 2026-10-06): `audit.csv` gains `suggested_account` (up to three Accounts from member-service search by domain and name) and `acs_roles` (`role=count`); Org Service domains stay unused, EasyCLA only reads them for sanctions screening and never fills them | -| member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every Salesforce Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05). Dev check: register a non-member Account, edit one field in Salesforce, confirm its `b2b_org` document survives | member-service team (Prabodh) | direction confirmed 2026-10-05; [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) 2026-10-05: one-file change plus a switch, about 10,300 more Accounts, excludes sanctions-screening, project and personal Accounts, follow-ups on family access, LFX One slug ties and a gradual rollout; no date; gates non-member registration in prod | -| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple `user:@clients member team:global_org_admin`; prod deploy through the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) | Michal / Prabodh / Lukasz | SSM done 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple: Prabodh agreed 2026-10-06 to write it now (OpenFGA port-forward, as in dev); that needs prod cluster access, so the request goes to Jordan Evans in #lfx-architecture, who wrote `team:crowdfunding_approvers` the same way; deploy: #5240 open, dev E2E 2026-10-07, then merge and deploy | -| Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | hand-off to agree; Apex not on the M3 critical path | +| member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every company Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05) | member-service team (Prabodh) | ticket [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (2026-10-08): behind a switch, dev first; 18,329 company Accounts, 8,242 in `b2b_orgs` today, 10,087 more; `POST /b2b_orgs` refuses an excluded Account with "not eligible"; prod switched on after its follow-ups; v1-only Accounts and duplicate merges out of scope. Prabodh starts the week of 2026-10-12, no date. Gates M3.1, not M3 | +| Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple `user:@clients member team:global_org_admin`; prod deploy through the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) | Michal / Prabodh / Lukasz | done. SSM 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple written by Jordan Evans 2026-10-06 ([Slack](https://linuxfoundation.slack.com/archives/C02RMNFE9FF/p1791307004246079)) and confirmed by the prod dry run the same day; #5240 merged 2026-10-06, released as [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) 2026-10-07 (M3 backend and tool), follow-ups [#5241](https://github.com/linuxfoundation/easycla/pull/5241)/[#5242](https://github.com/linuxfoundation/easycla/pull/5242) (2026-10-07), [#5243](https://github.com/linuxfoundation/easycla/pull/5243)/[#5245](https://github.com/linuxfoundation/easycla/pull/5245), [#5246](https://github.com/linuxfoundation/easycla/pull/5246)/[#5247](https://github.com/linuxfoundation/easycla/pull/5247), [#5248](https://github.com/linuxfoundation/easycla/pull/5248)/[#5250](https://github.com/linuxfoundation/easycla/pull/5250) (2026-10-08, v2.10.1–v2.10.3). The M3 features in prod sit behind LaunchDarkly flags. The ACS rules for the CLA-manager actions are in prod with acs-cli [v0.2.11](https://github.com/linuxfoundation/acs-cli/releases/tag/v0.2.11) and acs [v0.1.92](https://github.com/linuxfoundation/acs/releases/tag/v0.1.92) (2026-10-08) | +| Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | on hold until M3.1: M3 creates no Accounts. Data Loader for the one-time bulk confirmed 2026-10-05; the Apex endpoint belongs to M3.2 | ### Phase 1 — prod audit and review pack (now, read-only) -1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. **Re-run 2026-10-06** with #5239 ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791286894816259)), no writes: 3,735 company rows audited; dry run `eligible=2339`: 333 pending rewrite, 43 manual, 1,963 `001…` groups held by the all-403 guard until the prod tuple exists; `audit.csv` now carries `suggested_account` and `acs_roles`; reports emailed (`audit.csv`, `unresolvable.csv`, `possible_duplicates.csv`, `run.log`). This is the review material for item 3. +1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. **Re-run 2026-10-06** with #5239 ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791286894816259)), no writes: 3,735 company rows audited; dry run `eligible=2339`: 333 pending rewrite, 43 manual, 1,963 `001…` groups held by the all-403 guard until the prod tuple exists; `audit.csv` now carries `suggested_account` and `acs_roles`; reports emailed (`audit.csv`, `unresolvable.csv`, `possible_duplicates.csv`, `run.log`). **Re-run with the prod tuple 2026-10-06** ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791351620832509?thread_ts=1791286894.816259&cid=C0BGU6YQC2F)), no writes: 2,340 groups: 825 live (registered, in the lens), 1,138 `unregistered` (GET 403: a live non-member Account and a dead ID look the same until the register POST runs; 16 are also unknown to the Org Service), 334 `lf…` pending mapping, 43 manual, 0 failed. Final lists for the sheet: 69 groups with a suggested Account (60 `lf…`, 9 without SFID); 128 duplicate groups / 312 rows by domain or name; 11 rows / 5 Accounts with the same SFID under different names (DNEG, Storm Reply, Disney Animation, Yandex, Proxima); 438 to confirm manually (43 manual, 61 unresolvable, 334 `lf…` without an Account). This is the review material for item 3. 2. Domains and candidate matches, automated (read-only). In the tool since #5239: website → registrable domain, shared-domain list, member-service search by domain and name → `suggested_account`. Not in the tool, and only if the review leaves too many rows unmatched (Lukasz takes the CCLA domain approval lists next): for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching (Phase 0, done in #5239). -3. One review sheet (Michal builds it from the 2026-10-06 reports) for Heather, Juansebastian and sales ops, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. +3. Member organizations first (M3): Lukasz builds, from the 2026-10-06 reports, the sheet of member organizations (Account in the lens today) whose EasyCLA rows sit on another ID or are duplicated; Michal writes its spec as a separate issue; Heather and Juansebastian decide `collapse` or `distinct` per group (Phase 4a). The full sheet for Heather, Juansebastian and sales ops follows for M3.1, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. 4. Monthly statistics for the standup: companies created per month, split by "has a CCLA" / "none", so the premature-row rate before and after the #2751 fix is visible. 5. Outputs: `map.csv` (§4), `decisions.csv` (runbook §4.1), the shared-domain list, the manual-set decisions recorded in #2749. @@ -147,25 +162,27 @@ Done when: every `matched` and every collapse has a named reviewer (Heather, Jua `ingest --apply --ids …` on a handful of dev groups, both routes, with a dev mapping; runbook §5 verification (member-service GET, Org Service GET, ACS scopes moved, `cla-groups` lists the CCLA, events history intact, Console and lens show the company). Only the member-service GET proves the Account is live: the Org Service also serves IDs the CRM no longer has, and Heimdall answers 403 until the org's FGA tuples land, which is asynchronous after registration, so retry before treating a fresh registration as missing. Include Ahmed's non-member edit check once the member-service change is on dev. Confirm with Lukasz that a prod `register` run only POSTs groups whose liveness GET returned 200 while 403 groups wait. -### Phase 3 — prod `register` (995 groups, no Salesforce writes) +### Phase 3 — prod `register` (no Salesforce writes) + +M3: nothing to register. The 825 live groups are the member Accounts the membership backfill registered; a `register` run on them is an idempotent re-post, useful only to republish a lost index or FGA message. The live non-member Accounts (inside the 1,138 `unregistered`) wait for M3.1: after #3401 the backfill registers every company Account itself, so the tool's register route is then only the daily sweep (Phase 6). Never register an ID the CRM does not serve; registration has no API undo. -Prod deploy (#5240), prod tuple, prod dry run with liveness, then `ingest --routes register --tranche 10 --apply` → verify three groups → 100 → rest. Member Accounts first (their GET returns 200 today); non-member Accounts only after the member-service rule change (Phase 0), otherwise they drop out of the lens index on their next Salesforce edit. Registration has no API undo, so never without member-service and never for an ID the CRM does not serve. +Done when: the three sampled member companies are visible in the lens to a user who already holds a `b2b_org` grant, and a second run reports `failed=0`. CLA-manager entry into the lens is the `cla_admin` relation (§6 item 11), separate work on the M3 critical path. -Done when: a second run reports `failed=0` (the register route keeps no state and re-posts every live group, which also republishes a lost index or FGA message), and the three sampled companies are visible in the lens to a user who already holds a `b2b_org` grant. CLA-manager entry into the lens is the `cla_admin` relation ([m3-fga-model.md](https://github.com/linuxfoundation/easycla/pull/5210)), separate work. +### Phase 4 — prod `rewrite` (mapping CSV) -### Phase 4 — prod `rewrite` (~1,290 groups, mapping CSV) +**4a — M3 member tranche (no Salesforce writes).** Input: the member sheet (Phase 1 item 3): member organizations whose EasyCLA rows carry a dead `001…`, an `lf…` ID or a duplicate row next to the row on the member Account. Heather or Juansebastian marks each group `collapse` (onto the member Account) or `distinct`; `map.csv` maps each approved old ID to the member Account with `approved=true`; then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → rest. The merge is at the Account level only (§3.9): the rows become signing entities of the member Account and nothing is deleted; row merges stay #2056. Duplicate Accounts on the Salesforce side are sales ops' to merge and are not part of this tranche. -Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. +**4b — after M3.1 (~1,290 groups).** Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then the same command, tranches 10 → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID and still sees the company in the Corporate Console (it finds the user's company through the Org Service profile, `GET /v1/me`, not through role scopes), and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. -### Phase 5 — manual set (64 companies) +### Phase 5 — manual set (64 companies, after M3.1) -Per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749 (each *leave* is a recorded exception to the §0 goal, not a route). Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. +A row whose target is a member Account joins Phase 4a. Otherwise, per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749 (each *leave* is a recorded exception to the §0 goal, not a route). Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. ### Phase 6 — steady state until Option A -- Daily sweep, `register` route only (`org-import-sweep.yml`): after ≥10 clean manual runs set `ORG_IMPORT_SWEEP_PROD=dry-run`, read the reports for a week, then `apply`. Newly signed CCLAs on live Accounts appear in the lens within a day. On both paths an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). +- Daily sweep, `register` route only (`org-import-sweep.yml`): after ≥10 clean manual runs set `ORG_IMPORT_SWEEP_PROD=dry-run`, read the reports for a week, then `apply`. Newly signed CCLAs on live Accounts appear in the lens within a day. After #3401 the member-service backfill and CDC consumer register every company Account themselves, so the sweep only covers the gap until their next run. On both paths an Account is created only after a CCLA exists, so no never-signed company reaches Salesforce (the #2751 concern). - New `lf…` groups (v4 `CreateOrg` keeps minting them, ~16/month) show up as `pending` → one mapping batch per month through Phases 1 and 4, owner Michal. - #2751 in prod stops the premature rows; the monthly statistics (Phase 1 item 4) confirm it. @@ -260,12 +277,14 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 2 | Monthly `lf…` batch until Option A: who signs off the mapping each month (Michal proposes sales ops confirm matches, as in Phase 1) | Michal / sales ops | | 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | | 4 | Does ACS need the org in the Org Service when an org scope is created? Moot for the import (the tool waits for the Org Service first); test in the dev pilot anyway | us | -| 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05) | member-service team (Prabodh) | -| 6 | Prod tuple for the tool's client: Prabodh (2026-10-06) says write it now, the `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)) deletes only the old team's tuples; still to confirm that no prod `plan` exists yet (membership edits between `plan` and `cleanup` make later phases exit 4). Jordan Evans writes it (prod cluster access). Finish that cleanup before registering non-members in prod (its census gate counts Membership Assets) | us / Prabodh / Jordan | +| 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05). **Ticket 2026-10-08**: [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401), details in Phase 0; with the switch on the POST refuses an excluded Account with "not eligible". Gates M3.1 | member-service team (Prabodh) | +| 6 | Prod tuple for the tool's client. **Done 2026-10-06**: Jordan Evans wrote it ([Slack](https://linuxfoundation.slack.com/archives/C02RMNFE9FF/p1791307004246079)), Lukasz confirmed it with the prod dry run. The `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)) deletes only the old team's tuples; finish it before registering non-members in prod (M3.1; its census gate counts Membership Assets) | Prabodh | | 7 | `LFX_Org_Id__c`: not needed for the CSV bulk load; keep it only if the Apex path wants idempotency on `externalKey` (Eric doubts it is needed) | us / Midhun | | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | -| 9 | Prod deploy of the M3 backend (pure reads + tool): release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240), dev → main, same tree as dev; approve 2026-10-06, Lukasz merges and deploys 2026-10-07 after the dev E2E | Lukasz / us | +| 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | +| 11 | **CLA-manager entry into the lens (M3 critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The CLA page then checks every action against ACS (`/me/permissions/checks`), so today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Design: `cla_admin` on `b2b_org` ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)), one tuple per CLA manager per organization, projected from `signature_acl`. Pieces, each in another repo: the model change in lfx-v2-helm (Eric's review, version bump, model tests, PERMISSIONS.md); member-service adds `cla_admin` to `ExcludeRelations` so its full-sync publishes do not reap the tuples (#5210 §4.1); a publisher with `user:` subjects from auth-service lookups, plus a backfill for the existing CLA managers of member companies; Self Serve: organization selector and lens entry accept `cla_admin`, EasyCLA tab only. Product answers pending from Heather ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)): a CLA-manager-only user sees just the EasyCLA tab; do org admins who are not CLA managers see it. Not reachable by 2026-10-16/17: planned as its own track with its own date. Interim for prod testing: add the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / member-service / Self Serve | +| 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. @@ -297,3 +316,6 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-05** Luis: no prod cleanup of the global-org-admin migration run yet; away the week of 2026-10-06, Prabodh coordinates the prod tuple and cleanup (§6 item 6). SSM parameters confirmed applied in dev and prod; prod member-service chart 0.10.24 already checks `team:global_org_admin`. Roles note added to §5; Corporate Console `/v1/me` check added to Phase 4. - **2026-10-06** Prabodh: write the prod tuple now, through an OpenFGA port-forward as in dev; cleanup deletes only the old team's tuples; no fga-sync cache bump needed, Heimdall checks member-service routes against OpenFGA directly and its prod check cache expires after 10 s; cleanup date "soon". Lukasz runs the prod audit and ingest dry-run (no writes); first register tranche after the tuple. Member Accounts do not wait for the cleanup, non-member Accounts do (§6 item 6). - **2026-10-06** Lukasz: [#5239](https://github.com/linuxfoundation/easycla/pull/5239) merged and on dev (all four asks of 2026-10-05; Org Service domains stay unused); prod audit and ingest dry run re-run with it, no writes: 2,339 eligible groups, 333 pending, 43 manual, 1,963 `001…` groups held by the all-403 guard; `audit.csv` carries `suggested_account` and `acs_roles`; reports emailed. Release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) up, dev E2E 2026-10-07, then merge and deploy. Prod client ID is SSM `cla-auth0-platform-client-id-prod`. Writing the tuple needs prod cluster access: request to Jordan Evans in #lfx-architecture, the way Efren got `team:crowdfunding_approvers` ([lfx-crowdfunding#303](https://github.com/linuxfoundation/lfx-crowdfunding/issues/303)). Phase 1 review sheet to be built from the 2026-10-06 reports for Heather and Juansebastian. +- **2026-10-07** Lukasz: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06 and released as [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) in prod (M3 backend and the import tool); [#5241](https://github.com/linuxfoundation/easycla/pull/5241)/[#5242](https://github.com/linuxfoundation/easycla/pull/5242) after the dev E2E. Prod tuple written by Jordan Evans on 2026-10-06 and confirmed; prod audit and dry run re-run with liveness, no writes: 825 live, 1,138 `unregistered`, 334 `lf…`, 43 manual, and the final lists for the review sheet (Phase 1 item 1). +- **2026-10-08** EasyCLA call with Heather: M3 is members only, target end of next week (2026-10-16/17), everything in prod behind LaunchDarkly flags; M3.1 = every live Account in the lens after [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401); M3.2 = the Self Serve create flow, then Console retirement (§0, §3). Next steps: Lukasz finishes validating the M3 releases and builds the member duplicate sheet, and clarifies the merge process (Account-level collapse in EasyCLA; Salesforce merges stay with sales ops); Michal writes the sheet's spec as a separate issue, re-cuts this plan into member-first steps and pushes the CLA-manager role into FGA (§6 item 11); Ahmed hides the signed-PDF download from non-CLA-managers ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405)), shows the auto-ECLA toggle disabled with an explanation ([#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)) and investigates lens performance (36 CLA groups on the LF organization; caching, DynamoDB); Heather follows up with Mike Dolan and Jory and writes the PRD (§6 item 12). "CLA manager role not yet available in prod" blocks Juansebastian's testing: §6 item 11. +- **2026-10-08** Heather (Slack): the PDF download stays for CLA managers only (option 1); the auto-ECLA toggle stays visible, greyed, with a note that only CLA managers can change it ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791449606951089?thread_ts=1791434802.755129&cid=C0BGU6YQC2F)). Prabodh: #3401 filed, work starts the week of 2026-10-12. Lukasz ([update](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791463074517709)): v2.10.1–v2.10.3 in prod ([#5245](https://github.com/linuxfoundation/easycla/pull/5245) 403 for an unauthorized CLA-manager removal, [#5247](https://github.com/linuxfoundation/easycla/pull/5247) legacy 502, [#5250](https://github.com/linuxfoundation/easycla/pull/5250) employee CSV truncation, cla-signatory rollback, test race); acs-cli v0.2.11 and acs v0.1.92 in prod with the CLA-manager rules. Still pending from Heather in Slack: the scope confirmation ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791446534303509)) and the two `cla_admin` product answers ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)). From d1b0da2297d870ad8937ce54b8b69da9306cfe70 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 8 Oct 2026 11:37:20 -0700 Subject: [PATCH 20/30] docs(m3): review company-admin scopes before Phase 4a copies them Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index a4ef778fc..9acfb5c84 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -285,6 +285,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | | 11 | **CLA-manager entry into the lens (M3 critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The CLA page then checks every action against ACS (`/me/permissions/checks`), so today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Design: `cla_admin` on `b2b_org` ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)), one tuple per CLA manager per organization, projected from `signature_acl`. Pieces, each in another repo: the model change in lfx-v2-helm (Eric's review, version bump, model tests, PERMISSIONS.md); member-service adds `cla_admin` to `ExcludeRelations` so its full-sync publishes do not reap the tuples (#5210 §4.1); a publisher with `user:` subjects from auth-service lookups, plus a backfill for the existing CLA managers of member companies; Self Serve: organization selector and lens entry accept `cla_admin`, EasyCLA tab only. Product answers pending from Heather ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)): a CLA-manager-only user sees just the EasyCLA tab; do org admins who are not CLA managers see it. Not reachable by 2026-10-16/17: planned as its own track with its own date. Interim for prod testing: add the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / member-service / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | +| 13 | Before Phase 4a: review the ACS `company-admin` scopes on each old ID before the tool copies them to the new Account | Michal / Lukasz | Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. From c6b014635030e628978e208dcb6c6b00065773c4 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 8 Oct 2026 12:40:03 -0700 Subject: [PATCH 21/30] docs(m3): record the 2026-10-08 standup decisions and the CLA-manager lens-entry path Heather confirmed members first on the standup, settled the PDF button and auto-ECLA toggle (both visible, greyed, with a note) and asked for the simplest cla_admin product answer. CLA-manager entry into the lens is M3 scope; section 6 item 11 now carries the recommended M3 path (a Self Serve gate on the caller's ACS cla-manager scopes, nothing else of the lens) and moves the cla_admin FGA relation to M3.1. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 9acfb5c84..01fbb5c8c 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -16,7 +16,7 @@ This file is the single place for the plan and its decisions. Tickets carry poin - **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. - **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Open for M3**: CLA-manager entry into the lens. Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization, so the `cla_admin` relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) is on the M3 critical path and nothing is built yet (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Recommended path: Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). - **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -109,7 +109,7 @@ Three stages (2026-10-08). Phase numbers are unchanged; the table says which sta | Stage | Who is in the lens | Work | Status | | --- | --- | --- | --- | -| **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (`cla_admin`, §6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet in progress (Lukasz); `cla_admin` not started | +| **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (§6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet in progress (Lukasz); CLA-manager lens entry: path proposed 2026-10-08, waiting on Eric's OK and a Self Serve owner | | **M3.1** | Every company whose ID is a live Account (995 by the §2 count: the 825 members plus the live non-member Accounts) | [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (Prabodh, from the week of 2026-10-12) registers every company Account by itself; then Phases 4b and 5 for the groups that need a new or matched Account (sales ops, Data Loader) | #3401 open; sales-ops hand-off on hold until then | | **M3.2** | New companies, created from Self Serve | Option A (§6 item 3): Self Serve creates the Account through member-service, then the EasyCLA company and the CCLA; EasyCLA stops minting `lf…` IDs; the Console is retired | not started | @@ -120,7 +120,7 @@ flowchart LR subgraph M3["M3 — members"] SH["member duplicate sheet
(Lukasz)"] --> RV["review: collapse / distinct
(Heather, Juansebastian)"] RV --> RW1["Phase 4a: rewrite onto
the member Account"] - CA["cla_admin: model, tuples,
Self Serve gate (#5210)"] --> CM["CLA managers manage
CCLAs in the lens"] + CA["CLA-manager lens entry:
Self Serve gate on ACS scopes
(§6 item 11)"] --> CM["CLA managers manage
CCLAs in the lens"] RW1 --> CM end subgraph M31["M3.1 — every Account"] @@ -166,7 +166,7 @@ Done when: every `matched` and every collapse has a named reviewer (Heather, Jua M3: nothing to register. The 825 live groups are the member Accounts the membership backfill registered; a `register` run on them is an idempotent re-post, useful only to republish a lost index or FGA message. The live non-member Accounts (inside the 1,138 `unregistered`) wait for M3.1: after #3401 the backfill registers every company Account itself, so the tool's register route is then only the daily sweep (Phase 6). Never register an ID the CRM does not serve; registration has no API undo. -Done when: the three sampled member companies are visible in the lens to a user who already holds a `b2b_org` grant, and a second run reports `failed=0`. CLA-manager entry into the lens is the `cla_admin` relation (§6 item 11), separate work on the M3 critical path. +Done when: the three sampled member companies are visible in the lens to a user who already holds a `b2b_org` grant, and a second run reports `failed=0`. CLA-manager entry into the lens is §6 item 11, separate work on the M3 critical path. ### Phase 4 — prod `rewrite` (mapping CSV) @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The CLA page then checks every action against ACS (`/me/permissions/checks`), so today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Design: `cla_admin` on `b2b_org` ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)), one tuple per CLA manager per organization, projected from `signature_acl`. Pieces, each in another repo: the model change in lfx-v2-helm (Eric's review, version bump, model tests, PERMISSIONS.md); member-service adds `cla_admin` to `ExcludeRelations` so its full-sync publishes do not reap the tuples (#5210 §4.1); a publisher with `user:` subjects from auth-service lookups, plus a backfill for the existing CLA managers of member companies; Self Serve: organization selector and lens entry accept `cla_admin`, EasyCLA tab only. Product answers pending from Heather ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)): a CLA-manager-only user sees just the EasyCLA tab; do org admins who are not CLA managers see it. Not reachable by 2026-10-16/17: planned as its own track with its own date. Interim for prod testing: add the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / member-service / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve lists the caller's ACS role scopes (`GET /acs/v1/api/users/{username}/scopes`, machine token, the reverse index of the per-action checks the CLA pages already make), keeps the `cla-manager` scopes whose company SFID is a `b2b_org` in the lens, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the ACS `company-admin` scopes on each old ID before the tool copies them to the new Account | Michal / Lukasz | @@ -318,5 +318,6 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-06** Prabodh: write the prod tuple now, through an OpenFGA port-forward as in dev; cleanup deletes only the old team's tuples; no fga-sync cache bump needed, Heimdall checks member-service routes against OpenFGA directly and its prod check cache expires after 10 s; cleanup date "soon". Lukasz runs the prod audit and ingest dry-run (no writes); first register tranche after the tuple. Member Accounts do not wait for the cleanup, non-member Accounts do (§6 item 6). - **2026-10-06** Lukasz: [#5239](https://github.com/linuxfoundation/easycla/pull/5239) merged and on dev (all four asks of 2026-10-05; Org Service domains stay unused); prod audit and ingest dry run re-run with it, no writes: 2,339 eligible groups, 333 pending, 43 manual, 1,963 `001…` groups held by the all-403 guard; `audit.csv` carries `suggested_account` and `acs_roles`; reports emailed. Release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) up, dev E2E 2026-10-07, then merge and deploy. Prod client ID is SSM `cla-auth0-platform-client-id-prod`. Writing the tuple needs prod cluster access: request to Jordan Evans in #lfx-architecture, the way Efren got `team:crowdfunding_approvers` ([lfx-crowdfunding#303](https://github.com/linuxfoundation/lfx-crowdfunding/issues/303)). Phase 1 review sheet to be built from the 2026-10-06 reports for Heather and Juansebastian. - **2026-10-07** Lukasz: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06 and released as [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) in prod (M3 backend and the import tool); [#5241](https://github.com/linuxfoundation/easycla/pull/5241)/[#5242](https://github.com/linuxfoundation/easycla/pull/5242) after the dev E2E. Prod tuple written by Jordan Evans on 2026-10-06 and confirmed; prod audit and dry run re-run with liveness, no writes: 825 live, 1,138 `unregistered`, 334 `lf…`, 43 manual, and the final lists for the review sheet (Phase 1 item 1). -- **2026-10-08** EasyCLA call with Heather: M3 is members only, target end of next week (2026-10-16/17), everything in prod behind LaunchDarkly flags; M3.1 = every live Account in the lens after [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401); M3.2 = the Self Serve create flow, then Console retirement (§0, §3). Next steps: Lukasz finishes validating the M3 releases and builds the member duplicate sheet, and clarifies the merge process (Account-level collapse in EasyCLA; Salesforce merges stay with sales ops); Michal writes the sheet's spec as a separate issue, re-cuts this plan into member-first steps and pushes the CLA-manager role into FGA (§6 item 11); Ahmed hides the signed-PDF download from non-CLA-managers ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405)), shows the auto-ECLA toggle disabled with an explanation ([#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)) and investigates lens performance (36 CLA groups on the LF organization; caching, DynamoDB); Heather follows up with Mike Dolan and Jory and writes the PRD (§6 item 12). "CLA manager role not yet available in prod" blocks Juansebastian's testing: §6 item 11. -- **2026-10-08** Heather (Slack): the PDF download stays for CLA managers only (option 1); the auto-ECLA toggle stays visible, greyed, with a note that only CLA managers can change it ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791449606951089?thread_ts=1791434802.755129&cid=C0BGU6YQC2F)). Prabodh: #3401 filed, work starts the week of 2026-10-12. Lukasz ([update](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791463074517709)): v2.10.1–v2.10.3 in prod ([#5245](https://github.com/linuxfoundation/easycla/pull/5245) 403 for an unauthorized CLA-manager removal, [#5247](https://github.com/linuxfoundation/easycla/pull/5247) legacy 502, [#5250](https://github.com/linuxfoundation/easycla/pull/5250) employee CSV truncation, cla-signatory rollback, test race); acs-cli v0.2.11 and acs v0.1.92 in prod with the CLA-manager rules. Still pending from Heather in Slack: the scope confirmation ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791446534303509)) and the two `cla_admin` product answers ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)). +- **2026-10-08** EasyCLA call with Heather: M3 is members only, target end of next week (2026-10-16/17), everything in prod behind LaunchDarkly flags; M3.1 = every live Account in the lens after [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401); M3.2 = the Self Serve create flow, then Console retirement (§0, §3). Next steps: Lukasz finishes validating the M3 releases and builds the member duplicate sheet, and clarifies the merge process (Account-level collapse in EasyCLA; Salesforce merges stay with sales ops); Michal writes the sheet's spec as a separate issue, re-cuts this plan into member-first steps and proposes the CLA-manager lens entry (§6 item 11); Ahmed shows the signed-PDF download ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405)) and the auto-ECLA toggle ([#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)) greyed out to non-CLA-managers with a note or mouse-over (settled on the standup, next entry) and investigates lens performance (36 CLA groups on the LF organization; caching, DynamoDB); Heather follows up with Mike Dolan and Jory and writes the PRD (§6 item 12). "CLA manager role not yet available in prod" blocks Juansebastian's testing: §6 item 11. +- **2026-10-08** Heather (Slack): the PDF download stays for CLA managers only (option 1); the auto-ECLA toggle stays visible, greyed, with a note that only CLA managers can change it ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791449606951089?thread_ts=1791434802.755129&cid=C0BGU6YQC2F)). Prabodh: #3401 filed, work starts the week of 2026-10-12. Lukasz ([update](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791463074517709)): v2.10.1–v2.10.3 in prod ([#5245](https://github.com/linuxfoundation/easycla/pull/5245) 403 for an unauthorized CLA-manager removal, [#5247](https://github.com/linuxfoundation/easycla/pull/5247) legacy 502, [#5250](https://github.com/linuxfoundation/easycla/pull/5250) employee CSV truncation, cla-signatory rollback, test race); acs-cli v0.2.11 and acs v0.1.92 in prod with the CLA-manager rules. The scope confirmation ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791446534303509)) and the two `cla_admin` product questions ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)) were answered on the standup (next entry). +- **2026-10-08** Standup (Heather): members first confirmed. The signed-PDF download button and the auto-ECLA toggle both stay visible to non-CLA-managers, greyed out, with a note or mouse-over (supersedes the PDF answer above). `cla_admin` product questions: whatever is simpler to implement, M3 has 1.5 weeks. CLA-manager entry into the lens for member organizations is M3 scope, not a separate track; the recommended path and the FGA follow-up are in §6 item 11. From 6eebdaafc238013aefb5f26c9c940dd446bf22a9 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 8 Oct 2026 12:53:37 -0700 Subject: [PATCH 22/30] docs(m3): correct the CLA-manager lens-entry path after checking Self Serve The scopes come from user-service GET /v1/me/permissions with the caller's own token, not from ACS with a machine token. The org shell (slug resolver, canonical record, roster documents) is auditor-gated, so CLA-manager-only organizations take their identity from EasyCLA GET /v4/company/external/{sfid} and open by SFID; the same shell work serves the later cla_admin path. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 01fbb5c8c..22992d1fe 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve lists the caller's ACS role scopes (`GET /acs/v1/api/users/{username}/scopes`, machine token, the reverse index of the per-action checks the CLA pages already make), keeps the `cla-manager` scopes whose company SFID is a `b2b_org` in the lens, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the ACS `company-admin` scopes on each old ID before the tool copies them to the new Account | Michal / Lukasz | From 5676d006df0c5731c04e3e2f491c8ec0626d0460 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 9 Oct 2026 16:12:39 -0700 Subject: [PATCH 23/30] docs(m3): record the member duplicate sheet and what the tool can collapse Lukasz's sheet (76 groups / 147 rows) is ready for review. Checked against the org-import code: 51 groups can be re-keyed in M3, 24 need a Salesforce merge (M3.1), 1 needs no import step. Phase 4a now states the export rules for decisions.csv and map.csv. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 22992d1fe..c5d0113ab 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -109,7 +109,7 @@ Three stages (2026-10-08). Phase numbers are unchanged; the table says which sta | Stage | Who is in the lens | Work | Status | | --- | --- | --- | --- | -| **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (§6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet in progress (Lukasz); CLA-manager lens entry: path proposed 2026-10-08, waiting on Eric's OK and a Self Serve owner | +| **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (§6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet ready 2026-10-09 (Lukasz, 76 groups), in review (Heather, Juansebastian); CLA-manager lens entry: path proposed 2026-10-08, waiting on Eric's OK and a Self Serve owner | | **M3.1** | Every company whose ID is a live Account (995 by the §2 count: the 825 members plus the live non-member Accounts) | [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (Prabodh, from the week of 2026-10-12) registers every company Account by itself; then Phases 4b and 5 for the groups that need a new or matched Account (sales ops, Data Loader) | #3401 open; sales-ops hand-off on hold until then | | **M3.2** | New companies, created from Self Serve | Option A (§6 item 3): Self Serve creates the Account through member-service, then the EasyCLA company and the CCLA; EasyCLA stops minting `lf…` IDs; the Console is retired | not started | @@ -152,7 +152,7 @@ flowchart LR 1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. **Re-run 2026-10-06** with #5239 ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791286894816259)), no writes: 3,735 company rows audited; dry run `eligible=2339`: 333 pending rewrite, 43 manual, 1,963 `001…` groups held by the all-403 guard until the prod tuple exists; `audit.csv` now carries `suggested_account` and `acs_roles`; reports emailed (`audit.csv`, `unresolvable.csv`, `possible_duplicates.csv`, `run.log`). **Re-run with the prod tuple 2026-10-06** ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791351620832509?thread_ts=1791286894.816259&cid=C0BGU6YQC2F)), no writes: 2,340 groups: 825 live (registered, in the lens), 1,138 `unregistered` (GET 403: a live non-member Account and a dead ID look the same until the register POST runs; 16 are also unknown to the Org Service), 334 `lf…` pending mapping, 43 manual, 0 failed. Final lists for the sheet: 69 groups with a suggested Account (60 `lf…`, 9 without SFID); 128 duplicate groups / 312 rows by domain or name; 11 rows / 5 Accounts with the same SFID under different names (DNEG, Storm Reply, Disney Animation, Yandex, Proxima); 438 to confirm manually (43 manual, 61 unresolvable, 334 `lf…` without an Account). This is the review material for item 3. 2. Domains and candidate matches, automated (read-only). In the tool since #5239: website → registrable domain, shared-domain list, member-service search by domain and name → `suggested_account`. Not in the tool, and only if the review leaves too many rows unmatched (Lukasz takes the CCLA domain approval lists next): for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching (Phase 0, done in #5239). -3. Member organizations first (M3): Lukasz builds, from the 2026-10-06 reports, the sheet of member organizations (Account in the lens today) whose EasyCLA rows sit on another ID or are duplicated; Michal writes its spec as a separate issue; Heather and Juansebastian decide `collapse` or `distinct` per group (Phase 4a). The full sheet for Heather, Juansebastian and sales ops follows for M3.1, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. +3. Member organizations first (M3): Lukasz builds, from the 2026-10-06 reports, the sheet of member organizations (Account in the lens today) whose EasyCLA rows sit on another ID or are duplicated (**done 2026-10-09**: [Member duplicates](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit), 76 groups / 147 rows, `decisions.csv` and `map.csv` values pre-filled per group); Michal writes its spec as a separate issue; Heather and Juansebastian decide `collapse` or `distinct` per group (Phase 4a). The full sheet for Heather, Juansebastian and sales ops follows for M3.1, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. 4. Monthly statistics for the standup: companies created per month, split by "has a CCLA" / "none", so the premature-row rate before and after the #2751 fix is visible. 5. Outputs: `map.csv` (§4), `decisions.csv` (runbook §4.1), the shared-domain list, the manual-set decisions recorded in #2749. @@ -170,7 +170,7 @@ Done when: the three sampled member companies are visible in the lens to a user ### Phase 4 — prod `rewrite` (mapping CSV) -**4a — M3 member tranche (no Salesforce writes).** Input: the member sheet (Phase 1 item 3): member organizations whose EasyCLA rows carry a dead `001…`, an `lf…` ID or a duplicate row next to the row on the member Account. Heather or Juansebastian marks each group `collapse` (onto the member Account) or `distinct`; `map.csv` maps each approved old ID to the member Account with `approved=true`; then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → rest. The merge is at the Account level only (§3.9): the rows become signing entities of the member Account and nothing is deleted; row merges stay #2056. Duplicate Accounts on the Salesforce side are sales ops' to merge and are not part of this tranche. +**4a — M3 member tranche (no Salesforce writes).** Input: the member sheet (Phase 1 item 3): member organizations whose EasyCLA rows carry a dead `001…`, an `lf…` ID or a duplicate row next to the row on the member Account. Heather or Juansebastian marks each group `collapse` (onto the member Account) or `distinct`; `map.csv` maps each approved old ID to the member Account with `approved=true`; then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → rest. The merge is at the Account level only (§3.9): the rows become signing entities of the member Account and nothing is deleted; row merges stay #2056. Duplicate Accounts on the Salesforce side are sales ops' to merge and are not part of this tranche. The tool re-keys only rows on an `lf…`, dead `001…`, empty or malformed ID (routes `rewrite` and `manual`; an empty-ID row is mapped by its `company_id`); a row on another live Account, registered or not, keeps its `register` route and ignores the mapping, so a `collapse` there waits for a Salesforce merge (M3.1). Export only `collapse` groups to `decisions.csv` and `map.csv`, one line per old ID: a `distinct` line needs two old IDs and no `target_sfid`, and a duplicate `old_id` rejects the whole mapping file. **4b — after M3.1 (~1,290 groups).** Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then the same command, tranches 10 → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. @@ -321,3 +321,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-08** EasyCLA call with Heather: M3 is members only, target end of next week (2026-10-16/17), everything in prod behind LaunchDarkly flags; M3.1 = every live Account in the lens after [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401); M3.2 = the Self Serve create flow, then Console retirement (§0, §3). Next steps: Lukasz finishes validating the M3 releases and builds the member duplicate sheet, and clarifies the merge process (Account-level collapse in EasyCLA; Salesforce merges stay with sales ops); Michal writes the sheet's spec as a separate issue, re-cuts this plan into member-first steps and proposes the CLA-manager lens entry (§6 item 11); Ahmed shows the signed-PDF download ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405)) and the auto-ECLA toggle ([#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)) greyed out to non-CLA-managers with a note or mouse-over (settled on the standup, next entry) and investigates lens performance (36 CLA groups on the LF organization; caching, DynamoDB); Heather follows up with Mike Dolan and Jory and writes the PRD (§6 item 12). "CLA manager role not yet available in prod" blocks Juansebastian's testing: §6 item 11. - **2026-10-08** Heather (Slack): the PDF download stays for CLA managers only (option 1); the auto-ECLA toggle stays visible, greyed, with a note that only CLA managers can change it ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791449606951089?thread_ts=1791434802.755129&cid=C0BGU6YQC2F)). Prabodh: #3401 filed, work starts the week of 2026-10-12. Lukasz ([update](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791463074517709)): v2.10.1–v2.10.3 in prod ([#5245](https://github.com/linuxfoundation/easycla/pull/5245) 403 for an unauthorized CLA-manager removal, [#5247](https://github.com/linuxfoundation/easycla/pull/5247) legacy 502, [#5250](https://github.com/linuxfoundation/easycla/pull/5250) employee CSV truncation, cla-signatory rollback, test race); acs-cli v0.2.11 and acs v0.1.92 in prod with the CLA-manager rules. The scope confirmation ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791446534303509)) and the two `cla_admin` product questions ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)) were answered on the standup (next entry). - **2026-10-08** Standup (Heather): members first confirmed. The signed-PDF download button and the auto-ECLA toggle both stay visible to non-CLA-managers, greyed out, with a note or mouse-over (supersedes the PDF answer above). `cla_admin` product questions: whatever is simpler to implement, M3 has 1.5 weeks. CLA-manager entry into the lens for member organizations is M3 scope, not a separate track; the recommended path and the FGA follow-up are in §6 item 11. +- **2026-10-09** Lukasz: member duplicate sheet ready ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791532160505249?thread_ts=1791525720.978309&cid=C0BGU6YQC2F), [sheet](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit)), built from the 2026-10-06 prod reports, no writes. Checked against the tool's code (`dev`): of the 76 groups, 51 hold a row the tool can re-key in M3; 24 hold only rows on another live Account, which only a Salesforce merge can collapse (M3.1); 1 (DNEG) has both rows on one Account already and needs no import step. Groups 21, 39 and 51 (Ensignia, Liatrio, Power Maroc) are labelled re-keyable, but their only candidate is an unregistered live Account, so they belong to the 24. Reviewers decide the 51 first (filter `route` = `rewrite` or `manual`). From c0c2537590da6660e332d7a02e7e82d856b462bb Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Sun, 11 Oct 2026 18:36:05 -0700 Subject: [PATCH 24/30] docs(m3): fold Ahmed's review into the CLA-manager lens-entry item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the four points from the 2026-10-09 review (new ACS call, resolver fallback, no members-only filter, Phase 4a first) and fix the §5 roles sentence that still routed lens entry through cla_admin. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index c5d0113ab..c137cd080 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -263,7 +263,7 @@ The tool runs once per distinct `company_external_id`. Several EasyCLA rows shar No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. -Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); CLA managers get `cla_admin` on the `b2b_org` for lens entry, designees and signatories get no relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)); the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). +Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); for M3 a CLA manager enters the lens through the ACS gate of §6 item 11 (no `b2b_org` relation; `cla_admin` is the M3.1 design of [#5210](https://github.com/linuxfoundation/easycla/pull/5210)), designees and signatories get no entry; the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1; the tool reports both as `unregistered` and, when every Account answers 403, stops classifying (#5239). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter: no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so behind the flag the gate also admits CLA managers of non-member companies, who see a CLA-only page for their own company (M3.1 shows it to them anyway after #3401) and nothing of any other organization; Heather to confirm; (d) the Phase 4a rewrite runs before the flag goes on in prod, so member CLA managers' scopes sit on the member Account first. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the ACS `company-admin` scopes on each old ID before the tool copies them to the new Account | Michal / Lukasz | From 454703c89307e3d1d4ebb8672b80632f2af349fa Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 12 Oct 2026 14:54:42 -0700 Subject: [PATCH 25/30] docs(m3): record the 2026-10-12 decisions Release scope for 2026-10-16 (members only, ACS gate now, cla_admin after), the members-only filter and the Phase 4a non-dependency as answered to Ahmed, and why the org-import ACS copy stays role-agnostic: Org Service GET /v1/me, the Corporate Console's "my company", resolves from the caller's organization-type scopes (contact, company-admin), so a grant left on the old ID would point the Console at the orphaned lf... org. Corrects the Phase 4 "Done when" note on how the Console finds the user's company. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index c137cd080..3e938aa65 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -14,9 +14,9 @@ This file is the single place for the plan and its decisions. Tickets carry poin - **Goal**: every EasyCLA company with an active CCLA points at a live Salesforce Account (`001…`) and is registered in member-service, so it appears in the Self Serve Organization lens. Internal `company_id`s never change; the import deletes or merges no EasyCLA row. - **Staged** (agreed with Heather on the 2026-10-08 call; §3): **M3** (target 2026-10-16/17) = member companies, the 825 groups whose Account is already in the lens, manage their CCLAs there; every other company keeps the Corporate Console, unchanged. **M3.1** = every company whose ID is a live Account appears in the lens by itself once member-service drops the membership-only filter ([lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401)); no EasyCLA release. **M3.2** = new companies are created from Self Serve (Eric's Option A), then the Console is retired. - **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. -- **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. +- **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. Not a release gate (2026-10-12). - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Recommended path: Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's reply pending): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). - **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -174,7 +174,7 @@ Done when: the three sampled member companies are visible in the lens to a user **4b — after M3.1 (~1,290 groups).** Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then the same command, tranches 10 → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. -Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID and still sees the company in the Corporate Console (it finds the user's company through the Org Service profile, `GET /v1/me`, not through role scopes), and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. +Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID and still sees the company in the Corporate Console (the Console's "my company" is Org Service `GET /v1/me`, which resolves to the caller's last `organization`-type ACS scope — `contact` or `company-admin` — so it follows the copy), and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. ### Phase 5 — manual set (64 companies, after M3.1) @@ -283,9 +283,9 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter: no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so behind the flag the gate also admits CLA managers of non-member companies, who see a CLA-only page for their own company (M3.1 shows it to them anyway after #3401) and nothing of any other organization; Heather to confirm; (d) the Phase 4a rewrite runs before the flag goes on in prod, so member CLA managers' scopes sit on the member Account first. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan) | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) members-only filter (Michal, 2026-10-12, [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F)): no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so Self Serve shows an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token (`generateM2MToken`, as the member-tiers and newsletter calls do); today only members have a `b2b_org`, and the same check hides `lf…` and dead `001…` IDs. Whether the M2M principal may read `b2b_orgs` (`auditor` on the org) is unverified in dev; after #3401 registration no longer implies membership, so the check must read `is_member` instead; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469), no reply yet | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | -| 13 | Before Phase 4a: review the ACS `company-admin` scopes on each old ID before the tool copies them to the new Account | Michal / Lukasz | +| 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. @@ -322,3 +322,5 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-08** Heather (Slack): the PDF download stays for CLA managers only (option 1); the auto-ECLA toggle stays visible, greyed, with a note that only CLA managers can change it ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791449606951089?thread_ts=1791434802.755129&cid=C0BGU6YQC2F)). Prabodh: #3401 filed, work starts the week of 2026-10-12. Lukasz ([update](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791463074517709)): v2.10.1–v2.10.3 in prod ([#5245](https://github.com/linuxfoundation/easycla/pull/5245) 403 for an unauthorized CLA-manager removal, [#5247](https://github.com/linuxfoundation/easycla/pull/5247) legacy 502, [#5250](https://github.com/linuxfoundation/easycla/pull/5250) employee CSV truncation, cla-signatory rollback, test race); acs-cli v0.2.11 and acs v0.1.92 in prod with the CLA-manager rules. The scope confirmation ([thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791446534303509)) and the two `cla_admin` product questions ([Dano's thread](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791299902626999)) were answered on the standup (next entry). - **2026-10-08** Standup (Heather): members first confirmed. The signed-PDF download button and the auto-ECLA toggle both stay visible to non-CLA-managers, greyed out, with a note or mouse-over (supersedes the PDF answer above). `cla_admin` product questions: whatever is simpler to implement, M3 has 1.5 weeks. CLA-manager entry into the lens for member organizations is M3 scope, not a separate track; the recommended path and the FGA follow-up are in §6 item 11. - **2026-10-09** Lukasz: member duplicate sheet ready ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791532160505249?thread_ts=1791525720.978309&cid=C0BGU6YQC2F), [sheet](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit)), built from the 2026-10-06 prod reports, no writes. Checked against the tool's code (`dev`): of the 76 groups, 51 hold a row the tool can re-key in M3; 24 hold only rows on another live Account, which only a Salesforce merge can collapse (M3.1); 1 (DNEG) has both rows on one Account already and needs no import step. Groups 21, 39 and 51 (Ensignia, Liatrio, Power Maroc) are labelled re-keyable, but their only candidate is an unregistered live Account, so they belong to the 24. Reviewers decide the 51 first (filter `route` = `rewrite` or `manual`). +- **2026-10-12** Michal: minimum release scope for 2026-10-16 (may slip by days, not weeks): members only; org admins see the whole lens; a CLA manager who is not an org admin gets a CLA-only page of their own company; non-member CLA managers get no entry and keep the Corporate Console, as do new CCLA companies; member companies sign CCLAs in the lens; row merges stay deferred (#2056); the Phase 4a rewrite (43 member groups) is not a release gate and runs once Heather or Juansebastian have reviewed the sheet. The ACS gate is the M3 entry and `cla_admin` starts right after the release (§6 item 11; Eric asked, no reply yet). Members-only filter and the 4a dependency answered to Ahmed in [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) (§6 item 11 c–d). +- **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. From 90c0af4c21972f4ff7a94ee69813ba4a381a60ed Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 12 Oct 2026 17:17:26 -0700 Subject: [PATCH 26/30] docs(m3): record the answers to Eric's two questions on the CLA-manager entry Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 3e938aa65..36bcbcee4 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -16,7 +16,7 @@ This file is the single place for the plan and its decisions. Tickets carry poin - **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. - **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. Not a release gate (2026-10-12). - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's reply pending): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's two questions answered the same day, §6 item 11): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). - **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) members-only filter (Michal, 2026-10-12, [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F)): no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so Self Serve shows an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token (`generateM2MToken`, as the member-tiers and newsletter calls do); today only members have a `b2b_org`, and the same check hides `lf…` and dead `001…` IDs. Whether the M2M principal may read `b2b_orgs` (`auditor` on the org) is unverified in dev; after #3401 registration no longer implies membership, so the check must read `is_member` instead; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469), no reply yet | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) members-only filter (Michal, 2026-10-12, [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F)): no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so Self Serve shows an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token (`generateM2MToken`, as the member-tiers and newsletter calls do); today only members have a `b2b_org`, and the same check hides `lf…` and dead `001…` IDs. Whether the M2M principal may read `b2b_orgs` (`auditor` on the org) is unverified in dev; after #3401 registration no longer implies membership, so the check must read `is_member` instead; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | @@ -324,3 +324,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-09** Lukasz: member duplicate sheet ready ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791532160505249?thread_ts=1791525720.978309&cid=C0BGU6YQC2F), [sheet](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit)), built from the 2026-10-06 prod reports, no writes. Checked against the tool's code (`dev`): of the 76 groups, 51 hold a row the tool can re-key in M3; 24 hold only rows on another live Account, which only a Salesforce merge can collapse (M3.1); 1 (DNEG) has both rows on one Account already and needs no import step. Groups 21, 39 and 51 (Ensignia, Liatrio, Power Maroc) are labelled re-keyable, but their only candidate is an unregistered live Account, so they belong to the 24. Reviewers decide the 51 first (filter `route` = `rewrite` or `manual`). - **2026-10-12** Michal: minimum release scope for 2026-10-16 (may slip by days, not weeks): members only; org admins see the whole lens; a CLA manager who is not an org admin gets a CLA-only page of their own company; non-member CLA managers get no entry and keep the Corporate Console, as do new CCLA companies; member companies sign CCLAs in the lens; row merges stay deferred (#2056); the Phase 4a rewrite (43 member groups) is not a release gate and runs once Heather or Juansebastian have reviewed the sheet. The ACS gate is the M3 entry and `cla_admin` starts right after the release (§6 item 11; Eric asked, no reply yet). Members-only filter and the 4a dependency answered to Ahmed in [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) (§6 item 11 c–d). - **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. +- **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; decision on dropping the filter pending. From d65304ca1c320fdd800628d19158451daffc6bc5 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 12 Oct 2026 17:48:35 -0700 Subject: [PATCH 27/30] docs(m3): drop the members-only filter from the ACS gate (option A) Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 36bcbcee4..e8e9622c2 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) members-only filter (Michal, 2026-10-12, [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F)): no source readable with the user's token tells a member Account from a live non-member one (the `b2b_org` read is the auditor gate itself), so Self Serve shows an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token (`generateM2MToken`, as the member-tiers and newsletter calls do); today only members have a `b2b_org`, and the same check hides `lf…` and dead `001…` IDs. Whether the M2M principal may read `b2b_orgs` (`auditor` on the org) is unverified in dev; after #3401 registration no longer implies membership, so the check must read `is_member` instead; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | @@ -324,4 +324,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-09** Lukasz: member duplicate sheet ready ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791532160505249?thread_ts=1791525720.978309&cid=C0BGU6YQC2F), [sheet](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit)), built from the 2026-10-06 prod reports, no writes. Checked against the tool's code (`dev`): of the 76 groups, 51 hold a row the tool can re-key in M3; 24 hold only rows on another live Account, which only a Salesforce merge can collapse (M3.1); 1 (DNEG) has both rows on one Account already and needs no import step. Groups 21, 39 and 51 (Ensignia, Liatrio, Power Maroc) are labelled re-keyable, but their only candidate is an unregistered live Account, so they belong to the 24. Reviewers decide the 51 first (filter `route` = `rewrite` or `manual`). - **2026-10-12** Michal: minimum release scope for 2026-10-16 (may slip by days, not weeks): members only; org admins see the whole lens; a CLA manager who is not an org admin gets a CLA-only page of their own company; non-member CLA managers get no entry and keep the Corporate Console, as do new CCLA companies; member companies sign CCLAs in the lens; row merges stay deferred (#2056); the Phase 4a rewrite (43 member groups) is not a release gate and runs once Heather or Juansebastian have reviewed the sheet. The ACS gate is the M3 entry and `cla_admin` starts right after the release (§6 item 11; Eric asked, no reply yet). Members-only filter and the 4a dependency answered to Ahmed in [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) (§6 item 11 c–d). - **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. -- **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; decision on dropping the filter pending. +- **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; filter dropped the same day (option A, Michal): see item 11 (c). From cc9aec92fc4af23c3ec770fc2d5dbe2d97bb4552 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 12 Oct 2026 17:53:54 -0700 Subject: [PATCH 28/30] docs(m3): link the Self Serve ticket for the ACS gate Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index e8e9622c2..a9a29c37e 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. Implementation ticket: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503) (opened 2026-10-12, assigned to Ahmed, target 2026-10-16). **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | @@ -324,4 +324,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-09** Lukasz: member duplicate sheet ready ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791532160505249?thread_ts=1791525720.978309&cid=C0BGU6YQC2F), [sheet](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit)), built from the 2026-10-06 prod reports, no writes. Checked against the tool's code (`dev`): of the 76 groups, 51 hold a row the tool can re-key in M3; 24 hold only rows on another live Account, which only a Salesforce merge can collapse (M3.1); 1 (DNEG) has both rows on one Account already and needs no import step. Groups 21, 39 and 51 (Ensignia, Liatrio, Power Maroc) are labelled re-keyable, but their only candidate is an unregistered live Account, so they belong to the 24. Reviewers decide the 51 first (filter `route` = `rewrite` or `manual`). - **2026-10-12** Michal: minimum release scope for 2026-10-16 (may slip by days, not weeks): members only; org admins see the whole lens; a CLA manager who is not an org admin gets a CLA-only page of their own company; non-member CLA managers get no entry and keep the Corporate Console, as do new CCLA companies; member companies sign CCLAs in the lens; row merges stay deferred (#2056); the Phase 4a rewrite (43 member groups) is not a release gate and runs once Heather or Juansebastian have reviewed the sheet. The ACS gate is the M3 entry and `cla_admin` starts right after the release (§6 item 11; Eric asked, no reply yet). Members-only filter and the 4a dependency answered to Ahmed in [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) (§6 item 11 c–d). - **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. -- **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; filter dropped the same day (option A, Michal): see item 11 (c). +- **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; filter dropped the same day (option A, Michal): see item 11 (c). Implementation ticket for the gate opened the same day: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503), assigned to Ahmed. From ce63eb2090eb0885ae1ff37589ff07487d25fa9b Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Tue, 13 Oct 2026 11:24:25 -0700 Subject: [PATCH 29/30] docs(m3): record Eric's approval of the ACS gate and the open auditor decision Eric approved the temporary ACS gate as an interim phase on 2026-10-13 and recommended that the permanent cla_admin relation include auditor. Record the recommendation and the resulting product decision for Heather (fold into auditor vs keep the separate relation) in item 11 and the decision log. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-b2b-org-import.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index a9a29c37e..421e042d4 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -16,7 +16,7 @@ This file is the single place for the plan and its decisions. Tickets carry poin - **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. - **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. Not a release gate (2026-10-12). - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's two questions answered the same day, §6 item 11): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's two questions answered the same day and the gate approved by Eric on 2026-10-13, §6 item 11): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). - **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -283,7 +283,7 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. Implementation ticket: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503) (opened 2026-10-12, assigned to Ahmed, target 2026-10-16). **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants` | Michal / Eric / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. Implementation ticket: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503) (opened 2026-10-12, assigned to Ahmed, target 2026-10-16). **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants`. **Eric, 2026-10-13** ([reply](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW)): the temporary gate is fine as an interim phase. For the permanent model he recommends that `cla_admin` be at least inclusive of `auditor`: the Self Serve [navigation model](https://github.com/linuxfoundation/lfx-self-serve/blob/main/docs/architecture/frontend/permission-persona-navigation-model-preread.md) makes `auditor` the permission for entering an organization context, and a non-auditor EasyCLA entry is a lens of its own, which he finds poor for users. He calls it a product decision. **Open with Heather**: option A, fold (`auditor: … or cla_admin`): every CLA manager of a member company gets the whole org lens (memberships, key contacts, People roster, documents, meetings), including managers of subsidiary CCLAs after a collapse and external managers; Self Serve only has to list `cla_admin` organizations in the selector, and the CLA-only shell is deleted with the gate. Option B, separate relation (current [m3-fga-model.md](m3-fga-model.md)): CLA data only, but the CLA-only shell and a CLA-route gate stay for good. Michal's view: A is simpler. The 2026-10-16 gate is unaffected either way: the BFF cannot grant upstream `auditor`, so it is CLA-only by construction | Michal / Eric / Heather / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | @@ -325,3 +325,4 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-12** Michal: minimum release scope for 2026-10-16 (may slip by days, not weeks): members only; org admins see the whole lens; a CLA manager who is not an org admin gets a CLA-only page of their own company; non-member CLA managers get no entry and keep the Corporate Console, as do new CCLA companies; member companies sign CCLAs in the lens; row merges stay deferred (#2056); the Phase 4a rewrite (43 member groups) is not a release gate and runs once Heather or Juansebastian have reviewed the sheet. The ACS gate is the M3 entry and `cla_admin` starts right after the release (§6 item 11; Eric asked, no reply yet). Members-only filter and the 4a dependency answered to Ahmed in [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) (§6 item 11 c–d). - **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. - **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; filter dropped the same day (option A, Michal): see item 11 (c). Implementation ticket for the gate opened the same day: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503), assigned to Ahmed. +- **2026-10-13** Eric [approved](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the temporary ACS gate as an interim phase and recommended that the permanent `cla_admin` include `auditor` (the Self Serve navigation model makes `auditor` the context-entry permission; a CLA-only entry would be a lens of its own). Product decision for Heather, recorded in §6 item 11: option A, fold into `auditor` (whole org lens for every CLA manager, less Self Serve code, one lens; exposure to subsidiary and external CLA managers) or option B, keep the separate relation and the CLA-only shell. Michal's view: A is simpler. Nothing changes for the 2026-10-16 gate. From 4ed3711350e97b631622541f5f15e59983ba60f7 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Wed, 21 Oct 2026 15:29:15 -0700 Subject: [PATCH 30/30] docs(m3): record the 2026-10-16/20/21 decisions and merge M3.1 into M3.2 - M3.1 and M3.2 form one milestone, M3.2 (lfx-self-serve#3797) - #3401 is behind a setting, off in staging and prod; prod import waits for it - CLA managers enter the lens through an FGA relation limited to the CLA section (product decision 2026-10-21); the v4 APIs keep ACS - New open items: conglomerate visibility, non-member organizations Co-Authored-By: Claude Opus 5.5 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-b2b-org-import.md | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/docs/easycla-ss-migration/m3-b2b-org-import.md b/docs/easycla-ss-migration/m3-b2b-org-import.md index 421e042d4..e3f3b9dfb 100644 --- a/docs/easycla-ss-migration/m3-b2b-org-import.md +++ b/docs/easycla-ss-migration/m3-b2b-org-import.md @@ -12,12 +12,12 @@ This file is the single place for the plan and its decisions. Tickets carry poin ## 0. Summary - **Goal**: every EasyCLA company with an active CCLA points at a live Salesforce Account (`001…`) and is registered in member-service, so it appears in the Self Serve Organization lens. Internal `company_id`s never change; the import deletes or merges no EasyCLA row. -- **Staged** (agreed with Heather on the 2026-10-08 call; §3): **M3** (target 2026-10-16/17) = member companies, the 825 groups whose Account is already in the lens, manage their CCLAs there; every other company keeps the Corporate Console, unchanged. **M3.1** = every company whose ID is a live Account appears in the lens by itself once member-service drops the membership-only filter ([lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401)); no EasyCLA release. **M3.2** = new companies are created from Self Serve (Eric's Option A), then the Console is retired. +- **Staged** (agreed with Heather on the 2026-10-08 call; §3): **M3** (target 2026-10-16/17) = member companies, the 825 groups whose Account is already in the lens, manage their CCLAs there; every other company keeps the Corporate Console, unchanged. **M3.2** (since 2026-10-20 one milestone, epic [lfx-self-serve#3797](https://github.com/linuxfoundation/lfx-self-serve/issues/3797); it absorbs the earlier "M3.2") = every company whose ID is a live Account appears in the lens once member-service drops the membership-only filter ([lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401)), the non-member tranches run, CLA managers enter the lens through an FGA relation limited to the CLA section (§6 item 14), new companies are created from Self Serve (Eric's Option A), then the Console is retired. M3 shipped to prod on 2026-10-19. - **Tool**: Lukasz's `org-import`, in prod since [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) (2026-10-07). Three routes per company group: `register` (ID already live), `rewrite` (dead `001…` or `lf…` → new ID from a reviewed mapping), `manual`. Prod audit and dry run with liveness done 2026-10-06; nothing written in prod yet. - **M3 import work**: nothing to register (the membership backfill already registered the member Accounts); rewrite the member organizations whose EasyCLA rows sit on another ID or are duplicated onto their member Account, from a reviewed sheet (Phase 4a). No Salesforce writes. Not a release gate (2026-10-12). - **Humans decide** (§3.8): every domain match, every collapse/distinct, every manual row, every new Account, and the disposition of companies without an active CCLA. The tool decides nothing about identity; it executes approved inputs. AI may propose candidates in the review sheet, labelled as proposals. -- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's two questions answered the same day and the gate approved by Eric on 2026-10-13, §6 item 11): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.1 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). -- **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.1, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). +- **Open for M3**: CLA-manager entry into the lens (in M3 scope, Heather on the 2026-10-08 standup). Today only org admins (`b2b_org` writer or auditor, membership key contacts) can open an organization. Path (decided 2026-10-12; Eric's two questions answered the same day and the gate approved by Eric on 2026-10-13, §6 item 11): Self Serve admits a CLA manager to the EasyCLA pages of their member organization from their ACS `cla-manager` scopes, nothing else of the lens; the `cla_admin` FGA relation ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)) follows as M3.2 work (§6 item 11). The member duplicate sheet (Lukasz) and its review (Heather, Juansebastian). +- **Not in M3**: creating Salesforce Accounts (Data Loader, Apex) and the non-member rewrite and manual tranches (M3.2, after #3401); the Self Serve create flow (M3.2); row merges (#2056); deleting the ~1.4k companies without an active CCLA; a members-only CCLA policy (§6 item 12, undecided). ## 1. One Salesforce org, and where the IDs come from @@ -105,13 +105,13 @@ The excluded set is large because EasyCLA minted rows without signing intent: 1, Replaces the step list of 2026-09-24 (tickets citing "§3 step N" mean that list: step 1 → Phase 5, step 2 → Phase 1, step 5 → Phases 0 and 3, step 7 → Phase 6). Status as of 2026-10-08. -Three stages (2026-10-08). Phase numbers are unchanged; the table says which stage each phase belongs to. +Three stages (2026-10-08); since 2026-10-20 the two post-M3 stages form one milestone, M3.2 (epic [lfx-self-serve#3797](https://github.com/linuxfoundation/lfx-self-serve/issues/3797), one story per track), labelled below as M3.2 — import and M3.2 — create flow. Phase numbers are unchanged; the table says which stage each phase belongs to. | Stage | Who is in the lens | Work | Status | | --- | --- | --- | --- | | **M3** (target 2026-10-16/17) | Member companies: the 825 groups whose Account the membership backfill registered. Lapsed members included (the rule has no status check) | Phase 4a: rewrite member organizations whose rows sit on another ID or are duplicated onto their member Account, human-reviewed. CLA-manager lens entry (§6 item 11). Self Serve QA follow-ups and lens performance (Ahmed) | tool in prod; member sheet ready 2026-10-09 (Lukasz, 76 groups), in review (Heather, Juansebastian); CLA-manager lens entry: path proposed 2026-10-08, waiting on Eric's OK and a Self Serve owner | -| **M3.1** | Every company whose ID is a live Account (995 by the §2 count: the 825 members plus the live non-member Accounts) | [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (Prabodh, from the week of 2026-10-12) registers every company Account by itself; then Phases 4b and 5 for the groups that need a new or matched Account (sales ops, Data Loader) | #3401 open; sales-ops hand-off on hold until then | -| **M3.2** | New companies, created from Self Serve | Option A (§6 item 3): Self Serve creates the Account through member-service, then the EasyCLA company and the CCLA; EasyCLA stops minting `lf…` IDs; the Console is retired | not started | +| **M3.2 — import** | Every company whose ID is a live Account (995 by the §2 count: the 825 members plus the live non-member Accounts) | [#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (Prabodh, from the week of 2026-10-12) registers every company Account by itself; then Phases 4b and 5 for the groups that need a new or matched Account (sales ops, Data Loader) | #3401 on in dev behind a setting, off in staging and prod; the prod import runs only after it is on in prod (2026-10-16, §6 item 5); sales-ops hand-off on hold until then | +| **M3.2 — create flow** | New companies, created from Self Serve; CLA managers in the lens through FGA | Option A (§6 item 3): Self Serve creates the Account through member-service, then the EasyCLA company and the CCLA; EasyCLA stops minting `lf…` IDs; the `cla_admin` relation limited to the CLA section (§6 item 14); the Console is retired | not started | Order of operations, who waits for whom: @@ -123,14 +123,15 @@ flowchart LR CA["CLA-manager lens entry:
Self Serve gate on ACS scopes
(§6 item 11)"] --> CM["CLA managers manage
CCLAs in the lens"] RW1 --> CM end - subgraph M31["M3.1 — every Account"] + subgraph M31["M3.2 — import: every Account"] F["#3401: member-service drops
the membership filter (Prabodh)"] --> ALL["every company Account
registered by the backfill"] ALL --> SO["sales ops: approve matches,
create Accounts → map.csv"] SO --> RW2["Phase 4b: rewrite
10 / 100 / rest"] --> MAN["Phase 5: manual"] MAN --> SW["Phase 6: daily sweep"] end - subgraph M32["M3.2"] - CF["Self Serve create flow
(Option A)"] --> RET["Console retirement"] + subgraph M32["M3.2 — create flow"] + FGA["cla_admin relation:
CLA section only (§6 item 14)"] --> RET["Console retirement"] + CF["Self Serve create flow
(Option A)"] --> RET end CM --> F SW --> CF @@ -144,15 +145,15 @@ flowchart LR | Dev access: Auth0 grant ([auth0-terraform#395](https://github.com/linuxfoundation/auth0-terraform/pull/395)), `team:global_org_admin` tuple, member-service Salesforce password | Michal / platform | done 2026-09-30 | | Tool follow-up: report a GET 403 as `unregistered`, not `error`, and let the register POST decide (its 404 already routes the group to rewrite); the 15- and 18-character forms of one ID count as one Account. Blocked the dead-`001…` rewrite tranches: a dead ID has no tuples, so its GET answers 403 (§5) | Lukasz | done ([linuxfoundation/easycla#5239](https://github.com/linuxfoundation/easycla/pull/5239), merged 2026-10-06); when every Account answers 403 the tool stops classifying (all-403 guard) instead of guessing, which is what the 2026-10-06 prod dry run shows until the tuple exists | | Tool follow-ups from the prod dry-run: merge overlapping duplicate groups; extend the shared-domain list (github.com, en.wikipedia.org, buymeacoffee.com, nonameaccount.com, localhost.localhost); match on the registrable domain; a suggested existing Account per row from the CRM through member-service; ACS roles per old ID | Lukasz | done (#5239, 2026-10-06): `audit.csv` gains `suggested_account` (up to three Accounts from member-service search by domain and name) and `acs_roles` (`role=count`); Org Service domains stay unused, EasyCLA only reads them for sanctions screening and never fills them | -| member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every company Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05) | member-service team (Prabodh) | ticket [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (2026-10-08): behind a switch, dev first; 18,329 company Accounts, 8,242 in `b2b_orgs` today, 10,087 more; `POST /b2b_orgs` refuses an excluded Account with "not eligible"; prod switched on after its follow-ups; v1-only Accounts and duplicate merges out of scope. Prabodh starts the week of 2026-10-12, no date. Gates M3.1, not M3 | +| member-service: remove the shared Account qualifying rule (`accountsSOQLWhere`, "has a Membership asset") so `b2b_orgs` tracks every company Account 1:1 — backfill, CDC consumer and child lookups share it ([Ahmed, #2750, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750); Eric, 2026-10-05) | member-service team (Prabodh) | ticket [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401) (2026-10-08): behind a switch, dev first; 18,329 company Accounts, 8,242 in `b2b_orgs` today, 10,087 more; `POST /b2b_orgs` refuses an excluded Account with "not eligible"; prod switched on after its follow-ups; v1-only Accounts and duplicate merges out of scope. Prabodh starts the week of 2026-10-12, no date. Gates M3.2, not M3 | | Prod: SSM `cla-member-service-base-url-prod`, `cla-member-service-auth0-audience-prod`, `cla-org-import-report-emails-prod`; prod tuple `user:@clients member team:global_org_admin`; prod deploy through the release PR [#5240](https://github.com/linuxfoundation/easycla/pull/5240) (dev → main) | Michal / Prabodh / Lukasz | done. SSM 2026-10-01 ([lfx-easycla-terraform#68](https://github.com/linuxfoundation/lfx-easycla-terraform/pull/68)); tuple written by Jordan Evans 2026-10-06 ([Slack](https://linuxfoundation.slack.com/archives/C02RMNFE9FF/p1791307004246079)) and confirmed by the prod dry run the same day; #5240 merged 2026-10-06, released as [v2.10.0](https://github.com/linuxfoundation/easycla/releases/tag/v2.10.0) 2026-10-07 (M3 backend and tool), follow-ups [#5241](https://github.com/linuxfoundation/easycla/pull/5241)/[#5242](https://github.com/linuxfoundation/easycla/pull/5242) (2026-10-07), [#5243](https://github.com/linuxfoundation/easycla/pull/5243)/[#5245](https://github.com/linuxfoundation/easycla/pull/5245), [#5246](https://github.com/linuxfoundation/easycla/pull/5246)/[#5247](https://github.com/linuxfoundation/easycla/pull/5247), [#5248](https://github.com/linuxfoundation/easycla/pull/5248)/[#5250](https://github.com/linuxfoundation/easycla/pull/5250) (2026-10-08, v2.10.1–v2.10.3). The M3 features in prod sit behind LaunchDarkly flags. The ACS rules for the CLA-manager actions are in prod with acs-cli [v0.2.11](https://github.com/linuxfoundation/acs-cli/releases/tag/v0.2.11) and acs [v0.1.92](https://github.com/linuxfoundation/acs/releases/tag/v0.1.92) (2026-10-08) | -| Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | on hold until M3.1: M3 creates no Accounts. Data Loader for the one-time bulk confirmed 2026-10-05; the Apex endpoint belongs to M3.2 | +| Sales ops: agree the CSV hand-off (§4) and, separately, the Apex endpoint for the ongoing path | Michal / Mindy / Midhun | on hold until M3.2: M3 creates no Accounts. Data Loader for the one-time bulk confirmed 2026-10-05; the Apex endpoint belongs to M3.2 | ### Phase 1 — prod audit and review pack (now, read-only) 1. `STAGE=prod bin/org-import audit --no-aws-log --no-email` from a laptop: `audit.csv`, `unresolvable.csv`, `possible_duplicates.csv` (runbook §3.1). No member-service needed. **Done 2026-10-01** (Lukasz, [Slack](https://linuxfoundation.slack.com/archives/C0A8624SKV5/p1790851747101979)): counts match §2. Liveness did not run (no prod tuple), so every `001…` is `crm_unverified` until Phase 0 closes. Duplicates: 112 groups → 103 clusters; 20 clusters (46 CCLA companies) need a human merge/distinct decision, the other 83 hold at most one CCLA company. Unresolvable: 42 without ID, 18 dead in both, 1 malformed. `to_salesforce.csv`: the 332 `lf…` rows, no suggested Account yet; the dead `001…` join after liveness. **Re-run 2026-10-06** with #5239 ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791286894816259)), no writes: 3,735 company rows audited; dry run `eligible=2339`: 333 pending rewrite, 43 manual, 1,963 `001…` groups held by the all-403 guard until the prod tuple exists; `audit.csv` now carries `suggested_account` and `acs_roles`; reports emailed (`audit.csv`, `unresolvable.csv`, `possible_duplicates.csv`, `run.log`). **Re-run with the prod tuple 2026-10-06** ([Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791351620832509?thread_ts=1791286894.816259&cid=C0BGU6YQC2F)), no writes: 2,340 groups: 825 live (registered, in the lens), 1,138 `unregistered` (GET 403: a live non-member Account and a dead ID look the same until the register POST runs; 16 are also unknown to the Org Service), 334 `lf…` pending mapping, 43 manual, 0 failed. Final lists for the sheet: 69 groups with a suggested Account (60 `lf…`, 9 without SFID); 128 duplicate groups / 312 rows by domain or name; 11 rows / 5 Accounts with the same SFID under different names (DNEG, Storm Reply, Disney Animation, Yandex, Proxima); 438 to confirm manually (43 manual, 61 unresolvable, 334 `lf…` without an Account). This is the review material for item 3. 2. Domains and candidate matches, automated (read-only). In the tool since #5239: website → registrable domain, shared-domain list, member-service search by domain and name → `suggested_account`. Not in the tool, and only if the review leaves too many rows unmatched (Lukasz takes the CCLA domain approval lists next): for every dead `001…`, `lf…` and empty-ID group add the domains EasyCLA already holds: the CCLA's email-domain approval list (`domain_whitelist` on the signature), the CLA managers' emails (`signature_acl` → users), and the signatory email and company website in the CCLA signing form (the DocuSign XML/HTML kept with most CCLA signatures in the Snowflake signature raw/ingest table), shared domains excluded. The dead `001…` IDs cannot be mapped from data: the CRM mirror has no trace of them (not in the recycle bin, no `MasterRecordId`, §1.1), so the only exact mapping would be a sales-ops export from before the 2023 clean-up (§6 item 10); until then they follow the domain route. Join everything to live CRM Accounts by `Website` domain → proposed `matched` candidates (in the tool, which reads DynamoDB and the CRM through member-service live; Snowflake lags up to a day and serves only the overview); name-only hits get a Clearbit name-to-domain lookup (LF account) as a second opinion; everything else → to-create list. The same domains widen `possible_duplicates.csv` (today: normalized name or website domain) so duplicate groups are proposed by script too. The dry-run showed the website domain alone misfires: github.com, en.wikipedia.org and buymeacoffee.com appear as company domains, 11 rows keep a subdomain (startup.google.com, research.ibm.com), 3 have no website — hence the shared-domain list and registrable-domain matching (Phase 0, done in #5239). -3. Member organizations first (M3): Lukasz builds, from the 2026-10-06 reports, the sheet of member organizations (Account in the lens today) whose EasyCLA rows sit on another ID or are duplicated (**done 2026-10-09**: [Member duplicates](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit), 76 groups / 147 rows, `decisions.csv` and `map.csv` values pre-filled per group); Michal writes its spec as a separate issue; Heather and Juansebastian decide `collapse` or `distinct` per group (Phase 4a). The full sheet for Heather, Juansebastian and sales ops follows for M3.1, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. +3. Member organizations first (M3): Lukasz builds, from the 2026-10-06 reports, the sheet of member organizations (Account in the lens today) whose EasyCLA rows sit on another ID or are duplicated (**done 2026-10-09**: [Member duplicates](https://docs.google.com/spreadsheets/d/1ksQi768BDZwDZu6zoagWfPM5gF3_BfUhSwq2KLT4Hbc/edit), 76 groups / 147 rows, `decisions.csv` and `map.csv` values pre-filled per group); Michal writes its spec as a separate issue; Heather and Juansebastian decide `collapse` or `distinct` per group (Phase 4a). The full sheet for Heather, Juansebastian and sales ops follows for M3.2, tabs = §2.1 rows: *register* (information only), *proposed matches* (yes/no per row, reviewer), *to create* (sales ops), *duplicate groups* (collapse = one Account, the rows become its signing entities; distinct = separate legal entities such as NASA Ames / Goddard or SAP / SAP Labs Bulgaria; reviewer named; approved collapses feed the dry run, §5 step 3), *manual* (per row: Account to use, or leave), *no active CCLA* (later, Phase 7). Proposals are marked as proposals; nothing is approved until a person ticks it. 4. Monthly statistics for the standup: companies created per month, split by "has a CCLA" / "none", so the premature-row rate before and after the #2751 fix is visible. 5. Outputs: `map.csv` (§4), `decisions.csv` (runbook §4.1), the shared-domain list, the manual-set decisions recorded in #2749. @@ -164,19 +165,19 @@ Done when: every `matched` and every collapse has a named reviewer (Heather, Jua ### Phase 3 — prod `register` (no Salesforce writes) -M3: nothing to register. The 825 live groups are the member Accounts the membership backfill registered; a `register` run on them is an idempotent re-post, useful only to republish a lost index or FGA message. The live non-member Accounts (inside the 1,138 `unregistered`) wait for M3.1: after #3401 the backfill registers every company Account itself, so the tool's register route is then only the daily sweep (Phase 6). Never register an ID the CRM does not serve; registration has no API undo. +M3: nothing to register. The 825 live groups are the member Accounts the membership backfill registered; a `register` run on them is an idempotent re-post, useful only to republish a lost index or FGA message. The live non-member Accounts (inside the 1,138 `unregistered`) wait for M3.2: after #3401 the backfill registers every company Account itself, so the tool's register route is then only the daily sweep (Phase 6). Never register an ID the CRM does not serve; registration has no API undo. Done when: the three sampled member companies are visible in the lens to a user who already holds a `b2b_org` grant, and a second run reports `failed=0`. CLA-manager entry into the lens is §6 item 11, separate work on the M3 critical path. ### Phase 4 — prod `rewrite` (mapping CSV) -**4a — M3 member tranche (no Salesforce writes).** Input: the member sheet (Phase 1 item 3): member organizations whose EasyCLA rows carry a dead `001…`, an `lf…` ID or a duplicate row next to the row on the member Account. Heather or Juansebastian marks each group `collapse` (onto the member Account) or `distinct`; `map.csv` maps each approved old ID to the member Account with `approved=true`; then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → rest. The merge is at the Account level only (§3.9): the rows become signing entities of the member Account and nothing is deleted; row merges stay #2056. Duplicate Accounts on the Salesforce side are sales ops' to merge and are not part of this tranche. The tool re-keys only rows on an `lf…`, dead `001…`, empty or malformed ID (routes `rewrite` and `manual`; an empty-ID row is mapped by its `company_id`); a row on another live Account, registered or not, keeps its `register` route and ignores the mapping, so a `collapse` there waits for a Salesforce merge (M3.1). Export only `collapse` groups to `decisions.csv` and `map.csv`, one line per old ID: a `distinct` line needs two old IDs and no `target_sfid`, and a duplicate `old_id` rejects the whole mapping file. +**4a — M3 member tranche (no Salesforce writes).** Input: the member sheet (Phase 1 item 3): member organizations whose EasyCLA rows carry a dead `001…`, an `lf…` ID or a duplicate row next to the row on the member Account. Heather or Juansebastian marks each group `collapse` (onto the member Account) or `distinct`; `map.csv` maps each approved old ID to the member Account with `approved=true`; then `ingest --routes rewrite --mapping map.csv --decisions decisions.csv --state state.jsonl --tranche 10 --apply` → verify → rest. The merge is at the Account level only (§3.9): the rows become signing entities of the member Account and nothing is deleted; row merges stay #2056. Duplicate Accounts on the Salesforce side are sales ops' to merge and are not part of this tranche. The tool re-keys only rows on an `lf…`, dead `001…`, empty or malformed ID (routes `rewrite` and `manual`; an empty-ID row is mapped by its `company_id`); a row on another live Account, registered or not, keeps its `register` route and ignores the mapping, so a `collapse` there waits for a Salesforce merge (M3.2). Export only `collapse` groups to `decisions.csv` and `map.csv`, one line per old ID: a `distinct` line needs two old IDs and no `target_sfid`, and a duplicate `old_id` rejects the whole mapping file. -**4b — after M3.1 (~1,290 groups).** Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then the same command, tranches 10 → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. +**4b — after M3.2 (~1,290 groups).** Sales ops create the Accounts on the to-create list (Data Loader; record type, `AccountSource = EasyCLA`, owner = integration user) and return `map.csv`; approved matches carry `approved=true`. Then the same command, tranches 10 → verify → 100 → rest (runbook §5). Groups landing on one Account need a `collapse` decision; the lens `cla-groups` endpoint lists every row behind one SFID (verified 2026-10-01, `GetCompanyClaGroups` with child companies included), so a collapse hides no CCLA in the lens. After the last tranche, one run without `--tranche` for the events recheck. Done when: a repeat run reports `rewritten=0`, the events recheck lists nothing, every sampled CLA manager (an existing non-admin one included) can call the v4 endpoints on the new ID and still sees the company in the Corporate Console (the Console's "my company" is Org Service `GET /v1/me`, which resolves to the caller's last `organization`-type ACS scope — `contact` or `company-admin` — so it follows the copy), and a Contributor Console company search for a sampled rewritten company lists the new row with its CCLA and creates no ghost company under the old ID. -### Phase 5 — manual set (64 companies, after M3.1) +### Phase 5 — manual set (64 companies, after M3.2) A row whose target is a member Account joins Phase 4a. Otherwise, per row from the review sheet: a live Account chosen or created by sales ops → mapping row keyed by `company_id` for empty/malformed IDs (runbook §4) and the normal rewrite; or *leave* with the reason recorded in #2749 (each *leave* is a recorded exception to the §0 goal, not a route). Rows with CCLAs or ECLAs are never deleted. `CFF Migration` (550 ECLAs, no ID) gets its own decision. @@ -263,7 +264,7 @@ The tool runs once per distinct `company_external_id`. Several EasyCLA rows shar No second SFID column in EasyCLA: nothing needs the old ID after step 8, and it would not avoid the ACS move. -Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); for M3 a CLA manager enters the lens through the ACS gate of §6 item 11 (no `b2b_org` relation; `cla_admin` is the M3.1 design of [#5210](https://github.com/linuxfoundation/easycla/pull/5210)), designees and signatories get no entry; the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). +Roles: step 6 is the whole of the import's role handling. CLA manager, CLA manager designee and CLA signatory are ACS scopes keyed by the company SFID (org or project|org) and the copy is role-agnostic; `signature_acl`, `company_acl` and the signatory name and email on the signature hold usernames and emails, not SFIDs. How the roles work in M3 is documented elsewhere: ACS stays the system of record and Self Serve mirrors v4 ([role-mapping-feasibility.md](role-mapping-feasibility.md)); for M3 a CLA manager enters the lens through the ACS gate of §6 item 11 (no `b2b_org` relation; `cla_admin` is the M3.2 design of [#5210](https://github.com/linuxfoundation/easycla/pull/5210)), designees and signatories get no entry; the signatory keeps the DocuSign-by-email flow ([M3_ORG_LENS_API.md](../M3_ORG_LENS_API.md)); how a signatory who is not a CLA manager reaches the lens is still open (#5210, `m3-org-visibility.md` §4.5). Registration has no undo (no `DELETE /b2b_orgs/{id}`). `GET /b2b_orgs/{id}` returns 403 for every ID without `b2b_org` tuples (Heimdall checks `auditor` on that org), so a dead ID and a never-registered Account look the same to step 1; the tool reports both as `unregistered` and, when every Account answers 403, stops classifying (#5239). The register POST reads the Account from Salesforce directly, with no membership rule, so its 404 means the Account does not exist. Until the member-service qualifying rule is removed (§6 item 5), a registered non-member drops out of the lens index on its next Salesforce edit ([Luis, 2026-09-30](https://github.com/linuxfoundation/easycla/pull/5227#issuecomment-5919936125); [Ahmed, 2026-09-30](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)). So the tool runs in dev only and registers member Accounts only until then. The tool must normalise IDs to 18 characters before the GET: Heimdall checks the raw path value and the tuples are 18-character. @@ -277,17 +278,20 @@ Multi-row SFIDs after the import: the lens list (`GET /v4/company/external/{sfid | 2 | Monthly `lf…` batch until Option A: who signs off the mapping each month (Michal proposes sales ops confirm matches, as in Phase 1) | Michal / sales ops | | 3 | v4 creation post-M3: Eric recommends the Self-Service backend creates the B2B org through `POST /b2b_orgs` (Apex behind it) under its own M2M principal, and the CCLA carries the `001…` id; EasyCLA never calls it as a side effect ([gist](https://gist.github.com/emsearcy/1e3ad79bec685b23d21ee987201748d6), 2026-09-30; a recommendation). M3 relies on the sweep | us | | 4 | Does ACS need the org in the Org Service when an org scope is created? Moot for the import (the tool waits for the Org Service first); test in the dev pilot anyway | us | -| 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05). **Ticket 2026-10-08**: [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401), details in Phase 0; with the switch on the POST refuses an excluded Account with "not eligible". Gates M3.1 | member-service team (Prabodh) | -| 6 | Prod tuple for the tool's client. **Done 2026-10-06**: Jordan Evans wrote it ([Slack](https://linuxfoundation.slack.com/archives/C02RMNFE9FF/p1791307004246079)), Lukasz confirmed it with the prod dry run. The `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)) deletes only the old team's tuples; finish it before registering non-members in prod (M3.1; its census gate counts Membership Assets) | Prabodh | +| 5 | member-service: widen the shared qualifying rule, not only the backfill filter (Ahmed, 2026-09-30); Eric: drop the Member-only restriction entirely and ingest all Accounts (2026-09-30). Gates non-member registration in prod (§5). **Confirmed 2026-10-05** (Eric, Slack): `b2b_orgs` tracks every Salesforce Account 1:1, whatever brought it there (membership, training, events, EasyCLA, prospects), so LFX sees an existing Account before it creates a duplicate; the v1 member service stays member-only for Org Dashboard and Enrollment. Prabodh's [assessment](https://docs.google.com/document/d/16Tdm0aThHnHmiN_6k8yEcQj5FXbpxYmTdyvDG1jbS7k/edit) (2026-10-05): the change is small and safe (one file, a switch, dev first); scope should be "all company Accounts", excluding about 200 sanctions-screening, 45 project and 2 personal Accounts; open follow-ups are the company-family access rule and LFX One slug ties (3 → 76); no date. Two points touch this plan: his EasyCLA counts (3,564 companies with a Salesforce ID, about 2,100 on v1-only Accounts) cover every EasyCLA company, while §2 counts the 2,333 with an active CCLA that the import touches, so the two sets are not comparable one to one and should be labelled when quoted; and an excluded Account will still answer the GET with 403 like a dead ID, so the tool must not read a 403 as proof the Account is gone; whether the register POST accepts an excluded Account is asked of Prabodh (2026-10-05). **Ticket 2026-10-08**: [lfx-self-serve#3401](https://github.com/linuxfoundation/lfx-self-serve/issues/3401), details in Phase 0; with the switch on the POST refuses an excluded Account with "not eligible". **2026-10-16** (Prabodh, [Slack](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792149804349619?thread_ts=1791003155.842249&cid=C04DWU3FVMW)): shipped as an on/off setting (option C, chosen by Michal 2026-10-15): on in dev, off in staging and prod; turning prod on is a config change and a reload, turning it off stops new additions and removes nothing. A missing Account answers 404, an Account that cannot be an organization 422, and while the setting is off in prod a non-member Account answers 422 too, so the prod import ([lfx-self-serve#3957](https://github.com/linuxfoundation/lfx-self-serve/issues/3957)) runs only after the setting is on in prod. Gates M3.2 | member-service team (Prabodh) | +| 6 | Prod tuple for the tool's client. **Done 2026-10-06**: Jordan Evans wrote it ([Slack](https://linuxfoundation.slack.com/archives/C02RMNFE9FF/p1791307004246079)), Lukasz confirmed it with the prod dry run. The `cleanup` of the global-org-admin migration ([linuxfoundation/lfx-v2-member-service#120](https://github.com/linuxfoundation/lfx-v2-member-service/pull/120)) deletes only the old team's tuples; finish it before registering non-members in prod (M3.2; its census gate counts Membership Assets) | Prabodh | | 7 | `LFX_Org_Id__c`: not needed for the CSV bulk load; keep it only if the Apex path wants idempotency on `externalKey` (Eric doubts it is needed) | us / Midhun | | 8 | Companies without an active CCLA: not imported; rows kept until Console retirement, then only rows with no signature history are exported and deleted (decided 2026-10-02, §3 Phase 7). Heather informed | Michal | | 9 | Prod deploy of the M3 backend (pure reads + tool). **Done**: [#5240](https://github.com/linuxfoundation/easycla/pull/5240) merged 2026-10-06, released as v2.10.0 on 2026-10-07; v2.10.1–v2.10.3 on 2026-10-08 (Phase 0) | Lukasz | | 10 | What happened in Salesforce to the ~58k platform-created Accounts that are gone since mid-2023 (bulk delete, merges, clean-up), and does an export or merge log from before that exist? Decides whether the 962 dead `001…` get an exact mapping or the domain route (§1.1). **Answered 2026-10-05** (Mindy, Tori): removed during the 2023 CRM project as non-B2B; the recycle bin keeps records about a month, so no reinstatement — they are reloaded with new SFIDs; sales ops may still have the 2023 files. Domain route it is; the files, if found, improve the matching | Michal / Mindy | -| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.1 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. Implementation ticket: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503) (opened 2026-10-12, assigned to Ahmed, target 2026-10-16). **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.1 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.1 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants`. **Eric, 2026-10-13** ([reply](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW)): the temporary gate is fine as an interim phase. For the permanent model he recommends that `cla_admin` be at least inclusive of `auditor`: the Self Serve [navigation model](https://github.com/linuxfoundation/lfx-self-serve/blob/main/docs/architecture/frontend/permission-persona-navigation-model-preread.md) makes `auditor` the permission for entering an organization context, and a non-auditor EasyCLA entry is a lens of its own, which he finds poor for users. He calls it a product decision. **Open with Heather**: option A, fold (`auditor: … or cla_admin`): every CLA manager of a member company gets the whole org lens (memberships, key contacts, People roster, documents, meetings), including managers of subsidiary CCLAs after a collapse and external managers; Self Serve only has to list `cla_admin` organizations in the selector, and the CLA-only shell is deleted with the gate. Option B, separate relation (current [m3-fga-model.md](m3-fga-model.md)): CLA data only, but the CLA-only shell and a CLA-route gate stay for good. Michal's view: A is simpler. The 2026-10-16 gate is unaffected either way: the BFF cannot grant upstream `auditor`, so it is CLA-only by construction | Michal / Eric / Heather / Self Serve | +| 11 | **CLA-manager entry into the lens (M3 scope, critical path).** The lens opens only for `b2b_org` writers and auditors: org admins from member-service org settings, membership key contacts, LF staff. The organization selector and every CLA BFF route use that same gate; the CLA page then checks every action against ACS (`/me/permissions/checks`). So today only a CLA manager who is also an org admin can manage a CCLA in the lens; the others keep the Console. Heather (2026-10-08 standup): in M3 scope; for the two product questions, whatever is simpler. Simplest: a CLA manager who is not an org admin sees only the EasyCLA tab of their member organization; org admins keep seeing the tab as today, with the signed-PDF download and the auto-ECLA toggle greyed out and explained ([lfx-self-serve#3405](https://github.com/linuxfoundation/lfx-self-serve/issues/3405), [#3406](https://github.com/linuxfoundation/lfx-self-serve/issues/3406)). **Recommended path for M3 (Michal, 2026-10-08, needs Eric's OK): the gate reads ACS, not FGA.** Self Serve reads the caller's `cla-manager` scopes from user-service `GET /v1/me/permissions?role=cla-manager` with the caller's own token (the same hop as the `/me/permissions/checks` the CLA pages already make; no machine token), takes the company SFID from each `project|organization` scope, adds those organizations to the selector, lets the CLA BFF routes accept this qualification, and shows a CLA-manager-only user nothing but the EasyCLA tab. Checked 2026-10-08, the part that is not small: the organization shell is `auditor`-gated end to end (query-service `b2b_org` documents are private with `access_check_relation: auditor`, member-service `GET /b2b_orgs/{uid}` checks `auditor`, and the slug resolver, the canonical org record and the roster documents all read them with the caller's token), so a CLA manager without a `b2b_org` grant cannot open an organization page today. For those organizations Self Serve takes the name and SFID from EasyCLA `GET /v4/company/external/{sfid}` with the user's token (EasyCLA accepts the `project|organization` scope) and opens the lens by SFID, not by the member-service slug. Reading the `b2b_org` documents with the BFF machine token instead is not an option: the BFF forwards the user's own token ([lfx-object-store-design](https://github.com/linuxfoundation/lfx-skills/blob/main/skills/lfx-object-store-design/SKILL.md)). Non-CLA routes keep the writer/auditor gate, so no CLA manager gains org-wide read (spec 044 Q4). One repo, no model change, no tuples, no backfill, and the lens follows ACS the moment a CLA manager is added or removed; member organizations rewritten in Phase 4a qualify once their rows carry the member Account's SFID. The `cla_admin` design needs the same shell work (qualification, selector entry, EasyCLA-sourced identity, CLA-only navigation), so M3.2 swaps only the source of the qualification. Owner: Self Serve (Luis, Ahmed), behind the M3 LaunchDarkly flag. Implementation ticket: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503) (opened 2026-10-12, assigned to Ahmed, target 2026-10-16). **Agreed with Ahmed's review (2026-10-09, [comment](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4232493412)), 2026-10-11:** (a) the `GET /v1/me/permissions?role=cla-manager` call is new in Self Serve (today only `POST /me/permissions/checks` exists) and takes the same impersonation-token handling as `org-cla-permissions.service.ts`; (b) the org slug resolver (`org-slug-resolver.service.ts`) also needs the EasyCLA fallback: it resolves SFIDs through the query-service `b2b_org` document with the user's token, so a CLA manager without `auditor` fails there, not only in the selector; (c) no members-only filter (Michal, 2026-10-12, option A). The first idea, posted to Ahmed on [Slack](https://linuxfoundation.slack.com/archives/C0BGU6YQC2F/p1791840456204349?thread_ts=1791472039.049709&cid=C0BGU6YQC2F) and [GitHub](https://github.com/linuxfoundation/easycla/pull/5223#discussion_r4253059626), was to show an organization from the ACS scopes only when member-service `GET /b2b_orgs/{sfid}` answers 200 under the app's M2M token; it does not work: that client (`auth0_client.lfx_one`) holds `member` on `team:member_tiers_caller` only and no `auditor` on any `b2b_org`, so the read is 403 for every organization, and `is_member` is a field of the same `auditor`-gated record (Eric's question 2). Self Serve therefore admits every CLA manager whose ACS scope names an 18-character Salesforce ID to the CLA-only page of that company, member or not; that is the page M3.2 gives them anyway, and "members only" still holds for org admins because only member companies have writers or auditors in the lens. Scope IDs that are not Salesforce IDs (`lf…`) and SFIDs EasyCLA does not know (`GET /v4/company/external/{sfid}` 404) are skipped; (d) the Phase 4a rewrite is not a release gate: until it runs, the 43 member companies on old IDs show no CCLA to their org admins and their CLA managers keep the Corporate Console. Target 2026-10-16: CLA managers who are not org admins use the EasyCLA tab (Michal, 2026-10-11). **Why not `cla_admin` for M3**: it spans four repos (lfx-v2-helm model with Eric's review, version bump, model tests, PERMISSIONS.md; member-service adds `cla_admin` to its three exclusion lists so full-sync publishes do not reap the tuples, #5210 §4.1; a publisher and a backfill; Self Serve selector and gate), and EasyCLA has no NATS or fga-sync path today, so the publisher has no transport. It stays the M3.2 design ([#5210](https://github.com/linuxfoundation/easycla/pull/5210)): backfill in-cluster from `signature_acl` with `user:` subjects from auth-service `username_to_sub`, publisher to be decided, then Self Serve swaps the source behind the same qualification. Interim for prod testing until the gate ships: the testers as writer or auditor on the QA organization only (Prabodh or Jordan). **Decided 2026-10-12 (Michal)**: the ACS gate ships on 2026-10-16 and `cla_admin` starts right after the release; Eric asked in [#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469) and replied 2026-10-12 with two questions; Michal [answered](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791852377413989?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the same day. (1) Will `b2b_org#auditor` include `or cla_admin`? No, by design: [m3-fga-model.md](m3-fga-model.md) keeps `cla_admin: [user]` a separate relation with no org read; CLA routes check it directly and non-CLA routes keep `auditor`/`writer`, which is the CLA-only page of the release scope. Folding it into `auditor` would let every CLA manager read their company's full org lens (memberships, seats, voting history, employees, key contacts, documents); that is a product call for Heather, not taken. (2) Which generic `b2b_org` fetches fail for a user without a `b2b_org` grant? Three, all `auditor`-gated with the caller's token (checked in lfx-self-serve main, 2026-10-12): the slug resolver `GET /api/orgs/resolve/:segment` (query-service `b2b_org` documents), the canonical record `GET /api/orgs/uid/:uid` (member-service `GET /b2b_orgs/{uid}`, the source of name, logo and `is_member`), and the `requireOrgLensAccess` middleware on every `/api/orgs/:orgUid/lens/*` route. Handled by the CLA-only shell variant of (a)-(b) above, not by conditionals in the normal shell: no logo, no `is_member`, non-CLA routes stay gated; the ACS gate needs the variant on 2026-10-16 and `cla_admin` later only swaps the qualification source to `/my-grants`. **Eric, 2026-10-13** ([reply](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW)): the temporary gate is fine as an interim phase. For the permanent model he recommends that `cla_admin` be at least inclusive of `auditor`: the Self Serve [navigation model](https://github.com/linuxfoundation/lfx-self-serve/blob/main/docs/architecture/frontend/permission-persona-navigation-model-preread.md) makes `auditor` the permission for entering an organization context, and a non-auditor EasyCLA entry is a lens of its own, which he finds poor for users. He calls it a product decision. **Open with Heather**: option A, fold (`auditor: … or cla_admin`): every CLA manager of a member company gets the whole org lens (memberships, key contacts, People roster, documents, meetings), including managers of subsidiary CCLAs after a collapse and external managers; Self Serve only has to list `cla_admin` organizations in the selector, and the CLA-only shell is deleted with the gate. Option B, separate relation (current [m3-fga-model.md](m3-fga-model.md)): CLA data only, but the CLA-only shell and a CLA-route gate stay for good. Michal's view: A is simpler. The 2026-10-16 gate is unaffected either way: the BFF cannot grant upstream `auditor`, so it is CLA-only by construction. **Decided 2026-10-21** (product call: Heather, Dano, Luis, Michal; [summary](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792605157642049?thread_ts=1791003155.842249&cid=C04DWU3FVMW)): option B. The permanent relation admits CLA managers to the CLA section only, no `auditor`, because of privacy and self-certified managers; it may be widened later. Requirement and open points in item 14 | Michal / Eric / Heather / Self Serve | | 12 | **Members-only CCLAs** (Mike Dolan, DM to Michal, early October 2026): know whether a company is a member before it signs a CCLA and halt the signing until a membership agreement exists. Undecided; a product, legal and community discussion before any engineering (Heather: PRD, follow-up with Mike Dolan and Jory). No impact on M3; nothing in this plan assumes it | Heather | | 13 | Before Phase 4a: review the 7 ACS `company-admin` grants on the old IDs (groups 5, 16, 38, 40, 49, 59, 61) and decide per grant whether it lands on the member Account (copied as is) or is removed first. The copy itself stays role-agnostic (§7, 2026-10-12): `contact` and `company-admin` are the `organization`-type scopes that Org Service `GET /v1/me`, the Corporate Console's "my company", resolves from, so a grant left on the old ID would point the Console at the orphaned `lf…` org | Michal / Lukasz | +| 14 | **The CLA-manager relation in FGA (M3.2, [lfx-self-serve#3793](https://github.com/linuxfoundation/lfx-self-serve/issues/3793)).** Decided 2026-10-21 (item 11): a dedicated relation that admits CLA managers to the CLA section of their organization, not `auditor`; supersedes Eric's 2026-10-13 [recommendation](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW) as the product decision he deferred to (Eric to be told). Requirement (Michal, 2026-10-21; the behaviour v4 enforces today: the CLA group list `GET /v4/company/external/{sfid}/cla-groups` accepts any ACS scope on the organization, every action checks the `project|organization` scope of its CCLA): a CLA manager sees every CCLA of the organizations where they hold a role and manages only the CCLAs they are a manager of. So the FGA grant is per organization (lens entry only) and management stays per CCLA; Heather: "CLA admin is per CLA", no company-wide manager role exists. ACS stays the authority for the v4 APIs and is kept in sync with FGA (Michal's action item); a pure-FGA EasyCLA waits for the backend rewrite and the move to Kubernetes. Tuple source, proposal (Michal): project from `signature_acl`, the data the ACS `cla-manager` roles are derived from, publish on manager add and remove, backfill once, with the read-only ACS drift report as the cross-check ([m3-fga-model.md](m3-fga-model.md) §3); to confirm with Eric with the relation design. Open: designee and signatory entry ([lfx-self-serve#3796](https://github.com/linuxfoundation/lfx-self-serve/issues/3796)); conglomerate visibility (item 15) | Michal / Eric / Heather | +| 15 | **Conglomerate visibility for CLA managers (M3.2).** Prabodh, 2026-10-16 ([Slack](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792149804349619?thread_ts=1791003155.842249&cid=C04DWU3FVMW)): `auditor` and `writer` on `b2b_org` spread across the whole Salesforce family (parent, children, siblings, both directions); in prod 105 non-member companies sit under a member parent, 13 with active CCLAs (Sony Group → Sony Pictures Imageworks, 11 CCLAs; The Walt Disney Studios → Pixar 8, Industrial Light & Magic 2); after #3401 they join their families. Dano's [table](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792598072338119?thread_ts=1791003155.842249&cid=C04DWU3FVMW) assumed CLA access across the conglomerate; undecided on 2026-10-21 (whether a CLA manager sees or manages the CCLAs of related companies, and the data exposure for self-certified managers), Dano reviews the conglomerate models. Until decided, the relation of item 14 is scoped to the manager's own company, as the ACS gate is today | Dano / Heather | +| 16 | **Non-member organizations in the lens (M3.2).** After #3401 a non-member company is registered but has no org admin (no membership key contacts) and no organization profile; Luis builds CDP profiles for these companies in November, visible to LF staff only until the claim and affiliation workflows exist (Dano evaluates an in-app "Request Access" queue for org admins; Heather sees support friction from users who add an affiliation and cannot open the lens). Two consequences: the relation of item 14 is the only entry for a non-member CLA manager, so it must not be caught by the staff-only restriction (to confirm with Heather and Luis); and the non-member half of M3.2 ([lfx-self-serve#3957](https://github.com/linuxfoundation/lfx-self-serve/issues/3957), [#3955](https://github.com/linuxfoundation/lfx-self-serve/issues/3955)) cannot land before that work, so no date yet | Michal (ask) / Luis / Dano | -Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager authorization moves from ACS to FGA (Eric's proposal); CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. +Post-M3, parked: new orgs are created by the Self-Service backend through member-service `POST /b2b_orgs` (§6 item 3), EasyCLA orgs never become OpenFGA entities, and CLA-manager lens entry moves from ACS to FGA (§6 item 14) while the v4 APIs keep ACS until the backend rewrite; CCLA signatures key on the Salesforce account directly; Salesforce merges propagate via the member-service CDC consumer; orphaned `lf…` Org Service rows die with the Org Service. ## 7. Decision log @@ -326,3 +330,6 @@ Post-M3, parked: new orgs are created by the Self-Service backend through member - **2026-10-12** Org-import roles and the Corporate Console after the rewrite (checked in code: organization-service `MyOrganization`, lfx-kit `ResourceIDsByType`, lfx-corp-cla-console `getMyOrganization`): the Console's "my company" is Org Service `GET /v1/me`, which takes the caller's last `organization`-type ACS scope (`contact`, `company-admin`; the CLA roles are `project|organization`), so the tool's ACS copy stays role-agnostic and every old grant is deleted — a `contact` or `company-admin` left on the old ID would point the Console at the orphaned `lf…` org. The 7 `company-admin` grants on the 43 rows are reviewed by hand first (§6 item 13). The Org Service keeps both IDs: the member Account (synced from the CRM; every target name in the sheet came from it) and the untouched `lf…` org, which nothing in EasyCLA references after the rewrite (journal and `previous_company_external_id` only). EasyCLA v2 (`GetOrganization(company_external_id)`), the Corporate Console and the Org Dashboard (EasyCLA calls keyed on the organization's SFID, names from the Org Service) follow the new ID; 3 of the 40 `lf…` IDs already answer 404 in the Org Service, so those Console pages are broken today and the rewrite fixes them. - **2026-10-12** Eric's questions on the CLA-manager entry ([#lfx-architecture](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791832053210469)) and the answers recorded in §6 item 11: `cla_admin` stays a separate relation, `auditor` unchanged (folding it in is a product decision for Heather, not taken); the three `auditor`-gated shell fetches (slug resolver, canonical record, lens middleware) are bypassed by the CLA-only shell variant, which the 2026-10-16 ACS gate needs anyway. Consequence for the members-only filter in item 11 (c): `is_member` is one of the fetches that fails for these users and the M2M principal cannot read it either; filter dropped the same day (option A, Michal): see item 11 (c). Implementation ticket for the gate opened the same day: [lfx-self-serve#3503](https://github.com/linuxfoundation/lfx-self-serve/issues/3503), assigned to Ahmed. - **2026-10-13** Eric [approved](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1791908726863869?thread_ts=1791832053.210469&cid=C04DWU3FVMW) the temporary ACS gate as an interim phase and recommended that the permanent `cla_admin` include `auditor` (the Self Serve navigation model makes `auditor` the context-entry permission; a CLA-only entry would be a lens of its own). Product decision for Heather, recorded in §6 item 11: option A, fold into `auditor` (whole org lens for every CLA manager, less Self Serve code, one lens; exposure to subsidiary and external CLA managers) or option B, keep the separate relation and the CLA-only shell. Michal's view: A is simpler. Nothing changes for the 2026-10-16 gate. +- **2026-10-16** Prabodh ([Slack](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792149804349619?thread_ts=1791003155.842249&cid=C04DWU3FVMW)): #3401 ships as an on/off setting (option C): on in dev, off in staging and prod; a missing Account answers 404, an Account that cannot be an organization 422, and a non-member Account answers 422 while prod is off, so the prod import waits for the setting (§6 item 5). `auditor` and `writer` spread across the whole Salesforce family; 105 non-member companies sit under a member parent in prod, 13 with active CCLAs (§6 item 15). Michal: M3 touches member Accounts only and does not need the setting; the non-member steps wait for it. +- **2026-10-20** M3 milestone closed. "M3.1" and "M3.2" merged into one milestone, M3.2, epic [lfx-self-serve#3797](https://github.com/linuxfoundation/lfx-self-serve/issues/3797), with one story per track: the import ([#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)), new companies and Salesforce Accounts ([#3952](https://github.com/linuxfoundation/lfx-self-serve/issues/3952)), CLA managers in the lens through FGA ([#3953](https://github.com/linuxfoundation/lfx-self-serve/issues/3953)), lens follow-ups ([#3954](https://github.com/linuxfoundation/lfx-self-serve/issues/3954)) and the Console retirement ([#3955](https://github.com/linuxfoundation/lfx-self-serve/issues/3955)). This plan's "M3.1" is renamed M3.2 (the 2026-10-08 entry above keeps the old names). +- **2026-10-21** Product call (Heather, Dano, Luis, Michal; [summary](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792605157642049?thread_ts=1791003155.842249&cid=C04DWU3FVMW); Dano's role [table](https://linuxfoundation.slack.com/archives/C04DWU3FVMW/p1792598072338119?thread_ts=1791003155.842249&cid=C04DWU3FVMW) and Michal's answers earlier that day): the permanent CLA-manager entry is a dedicated FGA relation limited to the CLA section, not `auditor` (privacy, self-certified managers; may be widened later), option B of §6 item 11, superseding Eric's 2026-10-13 recommendation as the product decision he deferred to. The EasyCLA APIs stay on ACS, which is synced to FGA for the lens; pure FGA waits for the backend rewrite and Kubernetes (Michal owns the sync). Heather: the CLA manager role is per CLA, no company-wide "CLA admin" exists; the requirement is in §6 item 14. Conglomerate visibility for CLA managers undecided (Dano, §6 item 15). Luis builds CDP profiles for non-member companies in November, LF-staff-only until the claim and affiliation workflows exist; Dano evaluates an in-app "Request Access" queue (§6 item 16).