From 3ef8f5de4abe73f181e7592269b240762efe735b Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 09:47:37 -0700 Subject: [PATCH 01/23] docs: M3 org visibility & access proposal for architecture call 75% of EasyCLA orgs (69% of orgs with active CCLAs) are non-member orgs and cannot appear in the Self Serve Org Lens. Proposes: sync CLA-manager roles to OpenFGA via lfx-v1-sync-helper, admit EasyCLA-referenced Accounts into member-service, keep CLA screens on the v4 API gateway path. Co-Authored-By: Claude Fable 5 Signed-off-by: Michal Lehotsky --- .../06-m3-org-visibility-and-access.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md new file mode 100644 index 000000000..c95af9340 --- /dev/null +++ b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md @@ -0,0 +1,103 @@ + + + +# M3: EasyCLA Orgs in the Self Serve Org Lens — Visibility & Access + +**Status:** Proposal for architecture call · 2026-09-10 +**Problem:** The Self Serve Org Lens only shows LF **member** organizations. Most EasyCLA customers are **not** members — so most CLA managers would open Self Serve and see nothing. + +## The gap, quantified (prod data, Snowflake) + +| Metric | Count | +|---|---| +| EasyCLA companies with a Salesforce ID (SFID) | 3,531 | +| …visible in Self Serve today (member orgs) | **880 (25%)** | +| …invisible (non-member orgs) | **2,651 (75%)** | +| Orgs with an **active signed CCLA** | 2,277 | +| …of those, invisible in Self Serve | **1,581 (69%)** | + +Membership was measured with the exact gate member-service uses: Salesforce `Account` having an `Asset` with `Product2.Family = 'Membership'`. + +## Why they are invisible — two independent gates + +```mermaid +flowchart LR + SF["Salesforce Accounts
(~100k)"] -->|"gate 1: has Membership Asset
(~5,265 accounts)"| MS["member-service"] + MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] + MS -->|"writer / auditor tuples"| FGA["OpenFGA"] + QS -->|"org list"| SS["Self Serve
Org Lens"] + FGA -->|"gate 2: user must hold
a grant on the org"| SS + CLA[("EasyCLA DynamoDB
3,531 orgs, 2,651 non-member")] -.->|"no path"| SS +``` + +1. **The org record does not exist.** member-service only ingests Accounts with a Membership Asset, so a non-member org has no `b2b_org` document — nothing to list, search, or attach permissions to. +2. **The user has no grant.** Org Lens eligibility is an OpenFGA `writer`/`auditor` relation on `b2b_org:{sfid}`. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. + +## Proposal + +Split into three parts. Only one needs new pipeline work, and that pipeline already exists. + +```mermaid +flowchart LR + subgraph v1 ["EasyCLA v1 (AWS)"] + DDB[("DynamoDB
companies + CLA manager roles")] + V4["EasyCLA v4 API"] + end + subgraph platform ["LFX One platform"] + KV["NATS KV
v1-objects"] + FGA["OpenFGA"] + MS["member-service"] + QS["query-service"] + end + DDB -->|"A: DynamoDB Streams +
Meltano backfill (lfx-v1-sync-helper)"| KV + KV -->|"cla_manager tuples via
lfx.fga-sync.member_put/remove"| FGA + MS -->|"B: also admit EasyCLA orgs,
tagged non-member"| QS + SS["Self Serve"] -->|"org list"| QS + SS -->|"/access-check"| FGA + SS -->|"C: CLA tabs read/write
via API gateway (as in M1/M2)"| V4 +``` + +### A. Sync CLA-manager roles → OpenFGA (copy the meetings pattern) + +Add a **`cla_manager` relation on `b2b_org:{sfid}`**, fed the same way meetings feeds `host`/`participant`: + +- EasyCLA DynamoDB tables → `lfx-v1-sync-helper` (DynamoDB Streams + Meltano backfill → `v1-objects` KV). The repo has a step-by-step guide for onboarding a new table. +- A small event processor publishes `lfx.fga-sync.member_put` / `member_remove` (same contract the meeting service uses). +- One-time backfill: a few thousand tuples. + +Self Serve then treats `cla_manager` as lens-eligible (like `auditor`), but it unlocks **only the EasyCLA nav item** — not org profile management, key contacts, or other lens features. CLA managers are mostly *not* org admins, so a distinct narrow relation is safer than granting `auditor`. + +### B. Admit EasyCLA orgs into member-service (the policy decision) + +Relax the Membership-Asset gate for the ~2,651 Accounts referenced by EasyCLA, tagged so they are clearly distinguishable from member orgs (e.g. `source: easycla`, `is_member: false`). These are all real, curated Salesforce Accounts — EasyCLA v2 has always created companies through the Org Service. Scope stays bounded: only Accounts an EasyCLA company points at, not the whole Account table. + +**This is the schedule risk.** It is member-service work plus a trust/policy decision, and no pipeline work routes around it: without a `b2b_org` doc there is nothing to show and nothing to attach tuples to. + +### C. CLA screens keep calling EasyCLA v4 (no data replication in M3) + +M1/M2 already ship Self Serve server routes → API gateway → EasyCLA v4 in production (Me lens, sign flow). The M3 org-lens tabs are per-org detail views served fine by the same path, with EasyCLA enforcing authorization server-side as it does for the existing consoles. Replicating a dozen DynamoDB tables into OpenSearch buys nothing for these screens and does not fit the timeline. It can be a later milestone if search/list needs emerge. + +## Permissions sketch + +| EasyCLA role (ACS) | Org Lens effect | FGA relation | +|---|---|---| +| CLA manager | sees org in lens; full EasyCLA tab (approval lists, managers, acknowledgments) | `cla_manager` on `b2b_org` | +| CLA manager designee | sees org in lens; can initiate signing | `cla_manager` (same relation; v4 enforces the difference) | +| CLA signatory | no lens access needed (signs via DocuSign email) | none | +| Org admin (existing) | unchanged | `writer` | + +Enforcement stays two-layer: FGA gates what the UI *shows*; every EasyCLA write is enforced by v4/ACS server-side. + +## Decisions needed at this call + +1. **Admit non-member, EasyCLA-referenced Accounts into member-service?** (owner: member-service; the critical-path item) +2. **New `cla_manager` relation on `b2b_org` in the OpenFGA model?** (owner: platform / fga model in lfx-v2-helm) +3. **Confirm Self Serve → EasyCLA v4 via API gateway remains the approved pattern for M3** (M1/M2 precedent), so no CLA-data replication is in scope. + +## Prerequisites already tracked + +Data cleanup, needed regardless of the outcome ([parent story](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): + +- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — 230 companies with missing/invalid SFID are unreachable under any design +- [lfx-self-serve#2055](https://github.com/linuxfoundation/lfx-self-serve/issues/2055) — legacy `POST /v1/company` still creates companies with no Salesforce link +- [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) — duplicate company rows per SFID break resolution From b9b4535d379d7dd11f370275c221c80e98160e2a Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 13:44:52 -0700 Subject: [PATCH 02/23] docs: update M3 org visibility doc with architecture-call outcome Reflect the 2026-09-10 architecture call and spec 044 (rev 5): EasyCLA companies become real B2B Salesforce accounts (sales-ops approval pending, owned by Eric), member-service predicate widens to membership-or-CLA-referenced, permissions move to dedicated CLA FGA types derived from signature_acl, hard console cutover, and M3 ships on the bridge while the CLA service migration follows parity gates. Co-Authored-By: Claude Fable 5 Signed-off-by: Michal Lehotsky --- .../06-m3-org-visibility-and-access.md | 78 ++++++++----------- 1 file changed, 33 insertions(+), 45 deletions(-) diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md index c95af9340..08ccae845 100644 --- a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md +++ b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md @@ -3,7 +3,7 @@ # M3: EasyCLA Orgs in the Self Serve Org Lens — Visibility & Access -**Status:** Proposal for architecture call · 2026-09-10 +**Status:** Updated after the 2026-09-10 architecture call · aligned with `specs/044-lfx-v2-cla-service/` (rev 5) **Problem:** The Self Serve Org Lens only shows LF **member** organizations. Most EasyCLA customers are **not** members — so most CLA managers would open Self Serve and see nothing. ## The gap, quantified (prod data, Snowflake) @@ -18,6 +18,8 @@ Membership was measured with the exact gate member-service uses: Salesforce `Account` having an `Asset` with `Product2.Family = 'Membership'`. +> Eric's B2B-ingest analysis measures differently (domain matching against Salesforce; ~1,700 accounts to bring in, ~50% already mapping to existing Accounts). The two counts need reconciling in his proposal, but they describe the same gap. + ## Why they are invisible — two independent gates ```mermaid @@ -31,68 +33,54 @@ flowchart LR ``` 1. **The org record does not exist.** member-service only ingests Accounts with a Membership Asset, so a non-member org has no `b2b_org` document — nothing to list, search, or attach permissions to. -2. **The user has no grant.** Org Lens eligibility is an OpenFGA `writer`/`auditor` relation on `b2b_org:{sfid}`. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. +2. **The user has no grant.** Org Lens eligibility is an OpenFGA relation resolved against `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. + +## Direction agreed on the 2026-09-10 architecture call -## Proposal +**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** There will be no separate "EasyCLA organization" entity, no new B2C org type, and no parallel org catalogue. A CCLA attaches to a B2B account, the same way membership does. Eric is drafting the proposal to sales ops (Mindy) to approve ingesting the non-member CLA companies as B2B accounts — tracked in his "Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts" ticket. **This approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (explicitly acknowledged on the call). -Split into three parts. Only one needs new pipeline work, and that pipeline already exists. +The technical shape converges with the CLA-service plan (spec 044, rev 5): ```mermaid flowchart LR subgraph v1 ["EasyCLA v1 (AWS)"] - DDB[("DynamoDB
companies + CLA manager roles")] + DDB[("DynamoDB
companies + signatures
(signature_acl = managers)")] V4["EasyCLA v4 API"] end subgraph platform ["LFX One platform"] - KV["NATS KV
v1-objects"] - FGA["OpenFGA"] - MS["member-service"] + SH["lfx-v1-sync-helper
(streams + Meltano)"] + KV["NATS KV
cla-v1-objects"] + MS["member-service
predicate: membership
OR CLA-referenced"] + FGA["OpenFGA
cla_group / cla_ccla /
cla_ecla / cla_icla"] QS["query-service"] end - DDB -->|"A: DynamoDB Streams +
Meltano backfill (lfx-v1-sync-helper)"| KV - KV -->|"cla_manager tuples via
lfx.fga-sync.member_put/remove"| FGA - MS -->|"B: also admit EasyCLA orgs,
tagged non-member"| QS - SS["Self Serve"] -->|"org list"| QS + DDB --> SH --> KV + KV -->|"projector: ACL diff →
manager / signatory tuples"| FGA + KV -.->|"b2b_org_ensure for
unknown accounts"| MS + MS -->|"b2b_org docs + reindex"| QS + SS["Self Serve"] -->|"org list + selector union
(org read OR cla_ccla manager)"| QS SS -->|"/access-check"| FGA - SS -->|"C: CLA tabs read/write
via API gateway (as in M1/M2)"| V4 + SS -->|"CLA tabs: bridge (v4 via gateway)
until parity gate flips flags"| V4 ``` -### A. Sync CLA-manager roles → OpenFGA (copy the meetings pattern) - -Add a **`cla_manager` relation on `b2b_org:{sfid}`**, fed the same way meetings feeds `host`/`participant`: - -- EasyCLA DynamoDB tables → `lfx-v1-sync-helper` (DynamoDB Streams + Meltano backfill → `v1-objects` KV). The repo has a step-by-step guide for onboarding a new table. -- A small event processor publishes `lfx.fga-sync.member_put` / `member_remove` (same contract the meeting service uses). -- One-time backfill: a few thousand tuples. - -Self Serve then treats `cla_manager` as lens-eligible (like `auditor`), but it unlocks **only the EasyCLA nav item** — not org profile management, key contacts, or other lens features. CLA managers are mostly *not* org admins, so a distinct narrow relation is safer than granting `auditor`. - -### B. Admit EasyCLA orgs into member-service (the policy decision) +- **Org catalogue:** member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus a `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand; full `b2b_org` reindex afterwards. member-service remains the one Salesforce org service. +- **Permissions:** new FGA types per spec 044 — `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla` — confirmed on the call as needed **in this milestone** regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. +- **Lens entry:** the org selector becomes a union — org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla` — with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. +- **Console cutover:** hard cut, no parallel operation of the Corporate Console and the Org Lens (agreed on the call). That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a new backfill pass over CLA roles is required (spec 044 story H). -Relax the Membership-Asset gate for the ~2,651 Accounts referenced by EasyCLA, tagged so they are clearly distinguishable from member orgs (e.g. `source: easycla`, `is_member: false`). These are all real, curated Salesforce Accounts — EasyCLA v2 has always created companies through the Org Service. Scope stays bounded: only Accounts an EasyCLA company points at, not the whole Account table. +### What changed vs. the earlier version of this proposal -**This is the schedule risk.** It is member-service work plus a trust/policy decision, and no pipeline work routes around it: without a `b2b_org` doc there is nothing to show and nothing to attach tuples to. - -### C. CLA screens keep calling EasyCLA v4 (no data replication in M3) - -M1/M2 already ship Self Serve server routes → API gateway → EasyCLA v4 in production (Me lens, sign flow). The M3 org-lens tabs are per-org detail views served fine by the same path, with EasyCLA enforcing authorization server-side as it does for the existing consoles. Replicating a dozen DynamoDB tables into OpenSearch buys nothing for these screens and does not fit the timeline. It can be a later milestone if search/list needs emerge. - -## Permissions sketch - -| EasyCLA role (ACS) | Org Lens effect | FGA relation | -|---|---|---| -| CLA manager | sees org in lens; full EasyCLA tab (approval lists, managers, acknowledgments) | `cla_manager` on `b2b_org` | -| CLA manager designee | sees org in lens; can initiate signing | `cla_manager` (same relation; v4 enforces the difference) | -| CLA signatory | no lens access needed (signs via DocuSign email) | none | -| Org admin (existing) | unchanged | `writer` | - -Enforcement stays two-layer: FGA gates what the UI *shows*; every EasyCLA write is enforced by v4/ACS server-side. +| Earlier proposal (this doc, pre-call) | Now | +|---|---| +| New `cla_manager` relation on `b2b_org` | Superseded: dedicated CLA FGA types (`cla_ccla#manager` etc.), manager from `signature_acl` | +| Admit EasyCLA orgs "tagged non-member" | Confirmed and sharpened: they become real B2B accounts (sales-ops approval pending); member-service predicate widened | +| CLA tabs call v4 via gateway; no replication in M3 | Converges with spec 044's own rollout: pages run on the bridge behind per-env flags and flip to the CLA service only at zero parity differences | -## Decisions needed at this call +## Open items -1. **Admit non-member, EasyCLA-referenced Accounts into member-service?** (owner: member-service; the critical-path item) -2. **New `cla_manager` relation on `b2b_org` in the OpenFGA model?** (owner: platform / fga model in lfx-v2-helm) -3. **Confirm Self Serve → EasyCLA v4 via API gateway remains the approved pattern for M3** (M1/M2 precedent), so no CLA-data replication is in scope. +1. **Sales-ops approval** — Eric → Mindy; the blocker for the catalogue change. Heather and Michal to be invited to that conversation. +2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and member-service PR. +3. **M3 sequencing** — the minimum for M3 is the catalogue change, the FGA model + `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with the CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind the parity-gated flags and is not an M3 dependency. ## Prerequisites already tracked From 2d949209cdb9f71ddc7e52810ec2d8444b6a29d3 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 13:56:05 -0700 Subject: [PATCH 03/23] docs: correct M3 gap numbers against the B2B Salesforce org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit member-service reads the B2B Salesforce org (~18.2k accounts, carved out of the platform org with IDs preserved), not the old platform org EasyCLA SFIDs point at. Recomputed: only 1,362/3,531 EasyCLA orgs have a B2B-org account (1,048 visible today); 2,169 (61%) have no B2B-org account at all, so predicate widening alone covers only 314 orgs — the rest need Eric's account ingest plus a company_external_id remap. Co-Authored-By: Claude Fable 5 Signed-off-by: Michal Lehotsky --- .../06-m3-org-visibility-and-access.md | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md index 08ccae845..88342aa6d 100644 --- a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md +++ b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md @@ -8,33 +8,40 @@ ## The gap, quantified (prod data, Snowflake) +member-service reads the **B2B Salesforce org** (~18.2k accounts, carved out of the old platform Salesforce with record IDs preserved) — not the old platform org (~100k accounts) that EasyCLA's `company_external_id` values point at. Measured against the B2B org: + | Metric | Count | |---|---| | EasyCLA companies with a Salesforce ID (SFID) | 3,531 | -| …visible in Self Serve today (member orgs) | **880 (25%)** | -| …invisible (non-member orgs) | **2,651 (75%)** | -| Orgs with an **active signed CCLA** | 2,277 | -| …of those, invisible in Self Serve | **1,581 (69%)** | - -Membership was measured with the exact gate member-service uses: Salesforce `Account` having an `Asset` with `Product2.Family = 'Membership'`. +| …with an account in the **B2B Salesforce org** | 1,362 (39%) | +| ……of those, member → **visible in Self Serve today** | **1,048 (30%)** | +| ……present in B2B org but non-member | 314 | +| …with **no account in the B2B org at all** | **2,169 (61%)** | +| ……of those, still a live account in the old platform org | 2,082 | +| Orgs with an **active signed CCLA** | 2,278 | +| …visible today (member in B2B org) | 808 (35%) | +| …invisible: present-but-non-member / absent from B2B org | **180 / 1,290 (65% combined)** | -> Eric's B2B-ingest analysis measures differently (domain matching against Salesforce; ~1,700 accounts to bring in, ~50% already mapping to existing Accounts). The two counts need reconciling in his proposal, but they describe the same gap. +Membership uses the exact gate member-service applies: B2B-org `Account` having an `Asset` with `Product2.Family = 'Membership'` (8,065 accounts qualify). These figures line up with Eric's B2B-ingest ticket (~1,700 accounts to bring in; ~50% domain-matching to existing accounts): his ingest population ≈ the 2,169 absent accounts after domain-matching some to existing B2B accounts. ## Why they are invisible — two independent gates ```mermaid flowchart LR - SF["Salesforce Accounts
(~100k)"] -->|"gate 1: has Membership Asset
(~5,265 accounts)"| MS["member-service"] + OLD[("Old platform Salesforce
(~100k accounts — where
EasyCLA SFIDs point)")] -.->|"B2C decouple carved out
18.2k accounts, IDs preserved"| SF + SF["B2B Salesforce org
(~18.2k accounts)"] -->|"gate 1b: has Membership Asset
(8,065 accounts)"| MS["member-service"] MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] MS -->|"writer / auditor tuples"| FGA["OpenFGA"] QS -->|"org list"| SS["Self Serve
Org Lens"] FGA -->|"gate 2: user must hold
a grant on the org"| SS - CLA[("EasyCLA DynamoDB
3,531 orgs, 2,651 non-member")] -.->|"no path"| SS + CLA[("EasyCLA DynamoDB
3,531 orgs")] -.->|"gate 1a: 2,169 orgs have no
B2B-org account at all"| SF ``` -1. **The org record does not exist.** member-service only ingests Accounts with a Membership Asset, so a non-member org has no `b2b_org` document — nothing to list, search, or attach permissions to. +1. **The org record does not exist** — in two layers. (1a) 61% of EasyCLA orgs have **no account in the B2B Salesforce org** — they were left behind in the old platform org during the B2C decouple. No member-service predicate change can surface them; the accounts must be ingested (Eric's proposal). (1b) The 314 that do exist fail the Membership-Asset gate — the predicate widening covers exactly these. 2. **The user has no grant.** Org Lens eligibility is an OpenFGA relation resolved against `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. +> **ID remapping consequence:** accounts newly created in the B2B org get **new SFIDs** (Salesforce cannot create a record with a chosen ID). For those 2,169 orgs, EasyCLA's stored `company_external_id` will no longer resolve — the ingest must produce an old-ID → new-ID map, and EasyCLA (or the CLA service's mapping store) must apply it. The 1,362 already present carried their IDs over and need no remap. + ## Direction agreed on the 2026-09-10 architecture call **EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** There will be no separate "EasyCLA organization" entity, no new B2C org type, and no parallel org catalogue. A CCLA attaches to a B2B account, the same way membership does. Eric is drafting the proposal to sales ops (Mindy) to approve ingesting the non-member CLA companies as B2B accounts — tracked in his "Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts" ticket. **This approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (explicitly acknowledged on the call). @@ -78,7 +85,7 @@ flowchart LR ## Open items -1. **Sales-ops approval** — Eric → Mindy; the blocker for the catalogue change. Heather and Michal to be invited to that conversation. +1. **Sales-ops approval** — Eric → Mindy; the blocker for the catalogue change. Heather and Michal to be invited to that conversation. Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created** in the B2B org (new SFIDs → EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. 2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and member-service PR. 3. **M3 sequencing** — the minimum for M3 is the catalogue change, the FGA model + `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with the CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind the parity-gated flags and is not an M3 dependency. From caa9cfc62285577c832efdc3634ae85c72091bbd Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 19:38:18 -0700 Subject: [PATCH 04/23] docs(review): address PR #5210 review feedback Address review comments from Copilot, coderabbitai: - Link spec 044 by URL and flag that it lives in lfx-self-serve, not this repo; add an explicit supersedes note naming the three companion docs (00-overview, 03-milestone, spec.md) and epic lfx-self-serve#1968 that still defer CLA-in-OpenFGA to M5 (per Copilot) - Clarify that all org counts are distinct SFIDs (3,537 rows -> 3,531 distinct), citing lfx-self-serve#2056 for the duplicate rows (per Copilot) - Mark Eric's ~1,700 / ~50% ingest sizing as unreconciled against a shared denominator and not safe to size M3 from (per coderabbitai) - Name Eric Searcy and Heather Willson in full and record that the sales-ops ingest ticket is not findable in the org, so the critical-path dependency is currently untraceable (per Copilot) - Raise the cla_ccla#signatory selector gap as an open question: the union admits only #manager, locking out signatories with no b2b_org grant (per Copilot) - Add open item 4: SFID remap ordering must precede b2b_org_ensure, indexing, and tuple projection; unapplied maps fail silently (per coderabbitai) - Add open item 5: define required bridge/FGA parity behavior at cutover (per coderabbitai) Resolves 8 review threads. Signed-off-by: Michal Lehotsky --- .../06-m3-org-visibility-and-access.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md index 88342aa6d..1b5124c06 100644 --- a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md +++ b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md @@ -3,9 +3,13 @@ # M3: EasyCLA Orgs in the Self Serve Org Lens — Visibility & Access -**Status:** Updated after the 2026-09-10 architecture call · aligned with `specs/044-lfx-v2-cla-service/` (rev 5) +**Status:** Updated after the 2026-09-10 architecture call · aligned with [`specs/044-lfx-v2-cla-service/`](https://github.com/linuxfoundation/lfx-self-serve/tree/main/specs/044-lfx-v2-cla-service) (rev 5, in the `lfx-self-serve` repo — not in this one) **Problem:** The Self Serve Org Lens only shows LF **member** organizations. Most EasyCLA customers are **not** members — so most CLA managers would open Self Serve and see nothing. +> **Supersedes the "CLA-in-OpenFGA is M5" decision.** Three companion documents in this repo still record the earlier position that CLA object types enter the platform authorization model only at M5, and gate M3 on the ACS permission bridge instead: [`00-overview-fable.md`](00-overview-fable.md) (§3.3), [`03-milestone-ccla-org-lens-fable.md`](03-milestone-ccla-org-lens-fable.md) (option A recommended, "B deferred into M5"), and [`spec.md`](spec.md) ("Modeling CLA roles natively in the platform's fine-grained-authorization system is deferred to M5 scope"). The epic [lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) carries the same statement. The 2026-09-10 architecture call reversed this: the CLA FGA types are needed **in M3**. Those documents and the epic must be updated together with this one — until they are, two incompatible authorization architectures are documented side by side. +> +> Note what does **not** change: FGA governs **lens entry and UI gating**; EasyCLA v4 (via ACS) remains the **enforcement** point for every write through M3. That is two layers, not two systems of record — but see the parity requirement in open item 5. + ## The gap, quantified (prod data, Snowflake) member-service reads the **B2B Salesforce org** (~18.2k accounts, carved out of the old platform Salesforce with record IDs preserved) — not the old platform org (~100k accounts) that EasyCLA's `company_external_id` values point at. Measured against the B2B org: @@ -22,7 +26,9 @@ member-service reads the **B2B Salesforce org** (~18.2k accounts, carved out of | …visible today (member in B2B org) | 808 (35%) | | …invisible: present-but-non-member / absent from B2B org | **180 / 1,290 (65% combined)** | -Membership uses the exact gate member-service applies: B2B-org `Account` having an `Asset` with `Product2.Family = 'Membership'` (8,065 accounts qualify). These figures line up with Eric's B2B-ingest ticket (~1,700 accounts to bring in; ~50% domain-matching to existing accounts): his ingest population ≈ the 2,169 absent accounts after domain-matching some to existing B2B accounts. +All counts above are **distinct Salesforce IDs**, not EasyCLA company rows: 3,537 company rows with a well-formed SFID collapse to 3,531 distinct SFIDs (the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) accounts for the difference). Membership uses the exact gate member-service applies: B2B-org `Account` having an `Asset` with `Product2.Family = 'Membership'` (8,065 accounts qualify). + +**Reconciling with Eric's ingest sizing (unconfirmed).** Eric quoted ~1,700 accounts to bring in with ~50% domain-matching. The two figures have not been reconciled against a shared denominator, and this document should not be used to size M3 until they are. The plausible reading is that the 2,169 orgs absent from the B2B org are the starting population, ~50% of them domain-match an existing B2B account (needing a link rather than a new record), and ~1,700 is either the post-match creation count or a differently-scoped population altogether. **Which of those is meant, and what the 50% is a percentage of, needs confirmation from Eric** — see open item 1. ## Why they are invisible — two independent gates @@ -73,6 +79,7 @@ flowchart LR - **Org catalogue:** member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus a `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand; full `b2b_org` reindex afterwards. member-service remains the one Salesforce org service. - **Permissions:** new FGA types per spec 044 — `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla` — confirmed on the call as needed **in this milestone** regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. - **Lens entry:** the org selector becomes a union — org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla` — with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. + > **Open question — signatories.** Spec 044 models `cla_ccla` with both `manager` and `signatory`, but the selector union above (and rev 5's) admits only `manager`. A user who holds `signatory` and no `b2b_org` grant would be unable to enter the lens at all, which does not square with M3's signatory flow ([`spec.md`](spec.md) FR-030/FR-031). Either the union must include `signatory` — with relation-specific screen permissions so it does not confer manager or org-wide access — or the signatory flow must enter by some other route. Raised for Luis and Eric; not decided here. - **Console cutover:** hard cut, no parallel operation of the Corporate Console and the Org Lens (agreed on the call). That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a new backfill pass over CLA roles is required (spec 044 story H). ### What changed vs. the earlier version of this proposal @@ -85,10 +92,14 @@ flowchart LR ## Open items -1. **Sales-ops approval** — Eric → Mindy; the blocker for the catalogue change. Heather and Michal to be invited to that conversation. Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created** in the B2B org (new SFIDs → EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. +1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. Heather Willson and Michal Lehotsky to be invited to that conversation. Eric described the work on the call as a "Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts" ticket; **no such issue is findable in the `linuxfoundation` GitHub org at the time of writing** — the link must be added here once Eric files or identifies it, since this is the critical-path dependency for M3 and is currently untraceable. Confirm the ingest sizing with him at the same time (see "Reconciling with Eric's ingest sizing" above). Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created** in the B2B org (new SFIDs → EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. 2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and member-service PR. 3. **M3 sequencing** — the minimum for M3 is the catalogue change, the FGA model + `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with the CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind the parity-gated flags and is not an M3 dependency. +4. **SFID remap ordering, and its acceptance check.** The old-ID → new-ID map is not just an artifact to produce — it has a required position in the sequence. For any org whose account is newly created, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map fails silently: the org simply stays invisible with no signal. Ownership of producing and applying the map is unassigned (ingest side vs. EasyCLA vs. the spec-044 mapping store) and needs deciding. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID, and the org appears in the lens. + +5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. These are different inputs, so they can disagree in both directions — a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; what is still needed is the required parity behavior at cutover (which side wins, and what the acceptable divergence is when the flags flip). + ## Prerequisites already tracked Data cleanup, needed regardless of the outcome ([parent story](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): From 788159a6e00083cf2e6e2ca5c6b7f89a3cbbcf86 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 21:51:39 -0700 Subject: [PATCH 05/23] docs: reconcile M3 doc with Eric's published B2B ingest proposal Eric's proposal (lfx-architecture-scratch/2026-09-Consolidate-B2B-Backend, tracked in linuxfoundation/lfx-self-serve-ops#16) is now published. Resolve the previously-unconfirmed sizing reconciliation (SFID-presence vs domain matching, 2,169 vs 1,709), link the proposal from the direction section and open item 1, extend the SFID-remap item with his FK-plus-merge-participation mechanism, and note the 258 excluded companies against prerequisite #2054. Co-Authored-By: Claude Fable 5 Signed-off-by: Michal Lehotsky --- .../06-m3-org-visibility-and-access.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md index 1b5124c06..7cc5552e3 100644 --- a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md +++ b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md @@ -28,7 +28,16 @@ member-service reads the **B2B Salesforce org** (~18.2k accounts, carved out of All counts above are **distinct Salesforce IDs**, not EasyCLA company rows: 3,537 company rows with a well-formed SFID collapse to 3,531 distinct SFIDs (the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) accounts for the difference). Membership uses the exact gate member-service applies: B2B-org `Account` having an `Asset` with `Product2.Family = 'Membership'` (8,065 accounts qualify). -**Reconciling with Eric's ingest sizing (unconfirmed).** Eric quoted ~1,700 accounts to bring in with ~50% domain-matching. The two figures have not been reconciled against a shared denominator, and this document should not be used to size M3 until they are. The plausible reading is that the 2,169 orgs absent from the B2B org are the starting population, ~50% of them domain-match an existing B2B account (needing a link rather than a new record), and ~1,700 is either the post-match creation count or a differently-scoped population altogether. **Which of those is meant, and what the 50% is a percentage of, needs confirmation from Eric** — see open item 1. +**Reconciled with Eric's published sizing.** Eric's proposal is now published: [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) (method and reproduction in [TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16)). His numbers and the table above measure different populations with different matching keys — both hold: + +| | This doc | Eric's proposal | +|---|---|---| +| Population | 3,531 distinct stored SFIDs | 3,693 CCLA companies; 258 with a missing/broken org reference excluded → 3,435 | +| Matching key | stored SFID **present by ID** in the B2B org | normalized **domain** (EasyCLA → Org Service org → domain; EasyCLA itself stores no domain) against all 18,181 B2B accounts | +| Matches an existing B2B account | 1,362 | 1,725 | +| No B2B account | 2,169 | 1,710 → **1,709 distinct orgs to create** (+9.4% on 18,181) | + +The gap between 2,169 and 1,709 is a few hundred orgs whose stored SFID is absent from the B2B org but whose **domain matches an existing B2B account** — those need **linking** to the existing account, not a new record. Consequence for EasyCLA: the SFID remap (open item 4) covers the domain-linked orgs as well as the newly created ones — in both cases the stored `company_external_id` differs from the final B2B account ID. Steady state after the backfill: ~362 new CCLA companies/yr, of which ~196 (~16/month) need a new account. ## Why they are invisible — two independent gates @@ -46,11 +55,20 @@ flowchart LR 1. **The org record does not exist** — in two layers. (1a) 61% of EasyCLA orgs have **no account in the B2B Salesforce org** — they were left behind in the old platform org during the B2C decouple. No member-service predicate change can surface them; the accounts must be ingested (Eric's proposal). (1b) The 314 that do exist fail the Membership-Asset gate — the predicate widening covers exactly these. 2. **The user has no grant.** Org Lens eligibility is an OpenFGA relation resolved against `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. -> **ID remapping consequence:** accounts newly created in the B2B org get **new SFIDs** (Salesforce cannot create a record with a chosen ID). For those 2,169 orgs, EasyCLA's stored `company_external_id` will no longer resolve — the ingest must produce an old-ID → new-ID map, and EasyCLA (or the CLA service's mapping store) must apply it. The 1,362 already present carried their IDs over and need no remap. +> **ID remapping consequence:** accounts newly created in the B2B org get **new SFIDs** (Salesforce cannot create a record with a chosen ID). For those 2,169 orgs — the ~1,709 created *and* the domain-linked remainder — EasyCLA's stored `company_external_id` will no longer resolve; the ingest must produce an old-ID → new-ID map, and EasyCLA (or the CLA service's mapping store) must apply it. The 1,362 already present carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism: after import, each CCLA organization gets a foreign key to its Salesforce Account ID and **participates in future account merges** so the key follows the surviving record — in his words, "the part that does not exist today". Where that key lives and who applies it is open item 4. ## Direction agreed on the 2026-09-10 architecture call -**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** There will be no separate "EasyCLA organization" entity, no new B2C org type, and no parallel org catalogue. A CCLA attaches to a B2B account, the same way membership does. Eric is drafting the proposal to sales ops (Mindy) to approve ingesting the non-member CLA companies as B2B accounts — tracked in his "Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts" ticket. **This approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (explicitly acknowledged on the call). +**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** There will be no separate "EasyCLA organization" entity, no new B2C org type, and no parallel org catalogue. A CCLA attaches to a B2B account, the same way membership does. Eric's written proposal — [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) — is now published and going to sales ops (Mindy) for approval. **That approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (explicitly acknowledged on the call). + +Points of Eric's proposal that this document depends on: + +1. **CCLA signing is a recognized B2B onboarding path** — structurally parallel to member enrollment, minus the financial relationship. This is consistent with his earlier [organization-decoupling proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2024-12%20Decoupling%20orgs%20and%20users/README.md#a-proposal-for-organization-decoupling) (2024-12): B2B org records are created only when a user initiates a B2B flow "like Member Enrollment or Corporate CLA", and the parallel LFX org database keeps being sunset, not extended. +2. **LFX creates the ~1,709 accounts** (staged, reviewable, in tranches); Sales Ops approves the records and field semantics. `IsMember__c` stays **false** — which is exactly why the member-service predicate widening below remains necessary; the ingested accounts would otherwise still fail the Membership-Asset gate. +3. **The 258 companies with a missing/broken org reference are excluded** and remediated in EasyCLA (see prerequisites below) — the likeliest signal being the CLA managers' email domains, since EasyCLA stores no company domain. +4. **Future CCLA onboarding routes through Salesforce account creation at signing time** (end-user UX unchanged). *Which* service creates the account — a new membership-free Apex endpoint keeping matching rules in Salesforce, or LFX calling the standard Account API with its own domain dedupe — is an open governance question for Sales Ops in the proposal, not something this document needs to resolve. + +One mechanical note from [TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md) §5: member-service's existing `create-b2b-org` (`POST /b2b_orgs`) **registers** an Account that already exists in Salesforce — it does not create one, and performs no duplicate check. That is the same operation as the `b2b_org_ensure` request below; actual account creation happens upstream, on whichever path point 4 resolves to. The technical shape converges with the CLA-service plan (spec 044, rev 5): @@ -92,11 +110,11 @@ flowchart LR ## Open items -1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. Heather Willson and Michal Lehotsky to be invited to that conversation. Eric described the work on the call as a "Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts" ticket; **no such issue is findable in the `linuxfoundation` GitHub org at the time of writing** — the link must be added here once Eric files or identifies it, since this is the critical-path dependency for M3 and is currently untraceable. Confirm the ingest sizing with him at the same time (see "Reconciling with Eric's ingest sizing" above). Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created** in the B2B org (new SFIDs → EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. +1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published ([README](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16)) and Eric is starting the sales-ops conversation with Mindy; Heather Willson is out the week of 2026-09-14, so her involvement follows after. Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created or domain-linked** in the B2B org (→ EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. 2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and member-service PR. 3. **M3 sequencing** — the minimum for M3 is the catalogue change, the FGA model + `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with the CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind the parity-gated flags and is not an M3 dependency. -4. **SFID remap ordering, and its acceptance check.** The old-ID → new-ID map is not just an artifact to produce — it has a required position in the sequence. For any org whose account is newly created, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map fails silently: the org simply stays invisible with no signal. Ownership of producing and applying the map is unassigned (ingest side vs. EasyCLA vs. the spec-044 mapping store) and needs deciding. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID, and the org appears in the lens. +4. **SFID remap ordering, and its acceptance check.** The old-ID → new-ID map is not just an artifact to produce — it has a required position in the sequence. For any org whose account is newly created **or domain-linked to an existing account**, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map fails silently: the org simply stays invisible with no signal. Eric's proposal supplies the ongoing half of the mechanism (a foreign key on each CCLA organization to its Salesforce Account ID, updated on account merges), but ownership of producing and applying the map — and whether that key **is** EasyCLA's `company_external_id`, a new EasyCLA field, or the spec-044 mapping store — is unassigned and needs deciding. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID, and the org appears in the lens. 5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. These are different inputs, so they can disagree in both directions — a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; what is still needed is the required parity behavior at cutover (which side wins, and what the acceptable divergence is when the flags flip). @@ -104,6 +122,6 @@ flowchart LR Data cleanup, needed regardless of the outcome ([parent story](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): -- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — 230 companies with missing/invalid SFID are unreachable under any design +- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — 230 companies with missing/invalid SFID are unreachable under any design. This substantially overlaps Eric's 258 excluded companies (168 empty + 90 dangling references; counted against Org Service, hence the different total). His proposal excludes them from the ingest and puts remediation on EasyCLA; the one credible signal is the CLA managers' email domains (`company_acl` / `company_manager_id`), since EasyCLA stores no company domain — a per-record exercise, not a query. - [lfx-self-serve#2055](https://github.com/linuxfoundation/lfx-self-serve/issues/2055) — legacy `POST /v1/company` still creates companies with no Salesforce link - [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) — duplicate company rows per SFID break resolution From 922d5d135d3c0e04fe1ef3fac8c14ed58f2864b2 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 22:13:16 -0700 Subject: [PATCH 06/23] docs: correct M3 org visibility data and move to architecture folder The headline population figure was wrong. The 3,531 distinct company_external_id values in EasyCLA are not all Salesforce IDs: only 2,995 are SFID-shaped (001...), 530 are lf-prefixed IDs generated by the organization service, and 6 are malformed. The lf IDs are deliberately shaped to look like Salesforce IDs but are not, and none of the 530 resolve in the B2B org -- they can never match by ID, by construction. Every derived figure changes accordingly (all re-measured against prod Snowflake and cross-checked to reconcile): distinct real SFIDs 3,531 -> 2,995 absent from B2B org 2,169 (61%) -> 1,633 (55%) ... live in old platform 2,082 -> 1,559 (+74 dangling) active-CCLA orgs 2,278 -> 1,948 ... invisible 1,470 (65%) -> 1,140 (59%) Retracts the reconciliation claim from 978bd0089, which attributed the gap between our 2,169 and Eric's 1,709 to domain-linked orgs. Measured, that set is 374; the populations differed because our denominator silently included 536 non-Salesforce IDs. Both figures are now derived and the 374/11 overlap stated explicitly. Also: - Adds the ingest consequence of the lf-ID finding: those 530 orgs need an account created or domain-linked plus an ID remap, and are invisible to any SFID-keyed remediation. - Completes the supersession list. The M5 deferral of CLA-in-OpenFGA is recorded in five documents, not three -- including architecture- proposal.md P2, which is an architecture-review-approved decision (ARCH-406). Reversing it means reopening that approval, not just editing prose. - Softens the FR-030/FR-031 citation in the signatory open question; FR-031 defers role mapping back to the documents being superseded, so it does not independently settle the question. - Flags that a mislinked domain silently points a signed CCLA at the wrong company, and that Eric's staged review covers created records but not linked ones. - Restructures into numbered sections with a summary table up front. Moves the document from specs/001-easycla-ss-integration-fable/ to docs/easycla-ss-migration/ per that folder's stated split: it is a decision record and architecture evaluation, not an implementation spec. Renamed to m3-org-visibility.md to match the sibling naming. Adds it to the folder README reading order and drops the README's link to a root ARCHITECTURE.md that does not exist. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/README.md | 6 +- .../easycla-ss-migration/m3-org-visibility.md | 231 ++++++++++++++++++ .../06-m3-org-visibility-and-access.md | 127 ---------- 3 files changed, 234 insertions(+), 130 deletions(-) create mode 100644 docs/easycla-ss-migration/m3-org-visibility.md delete mode 100644 specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md diff --git a/docs/easycla-ss-migration/README.md b/docs/easycla-ss-migration/README.md index 099226cdb..b83c336d2 100644 --- a/docs/easycla-ss-migration/README.md +++ b/docs/easycla-ss-migration/README.md @@ -5,9 +5,9 @@ SPDX-License-Identifier: CC-BY-4.0 --> Self-contained materials for the architecture review of the EasyCLA-to-Self-Serve migration. Reading order: -1. **[ARCHITECTURE.md](../../ARCHITECTURE.md)** — start here for the **current** target architecture (M1–M3): cross-component contracts, authorization, and external dependencies, as a roll-up. The two documents below are the reviewed decision record behind it. -2. **[architecture-proposal.md](architecture-proposal.md)** — the reviewed proposal (Eric Searcy, 2026-07-20). Current state, milestones, what leadership already settled, the proposed architecture (P1–P10, including the audit and trusted-caller designs), top risks, and what the review should challenge. -3. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`. +1. **[architecture-proposal.md](architecture-proposal.md)** — the reviewed proposal (Eric Searcy, 2026-07-20). Current state, milestones, what leadership already settled, the proposed architecture (P1–P10, including the audit and trusted-caller designs), top risks, and what the review should challenge. +2. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`. +3. **[m3-org-visibility.md](m3-org-visibility.md)** — why most EasyCLA companies are invisible in the Org Lens and what makes them visible (2026-09-10 call outcome, with prod data). **Reverses P2's "no CLA object types in OpenFGA before M5"** — read it against item 1. 4. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session. Implementation-level specifications (milestone scopes, acceptance criteria, per-milestone plans — used by the Spec Kit workflow) live separately in [specs/001-easycla-ss-integration-fable/](../../specs/001-easycla-ss-integration-fable/spec.md). This folder is for evaluating the architecture; that folder is for building it. diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md new file mode 100644 index 000000000..fd470ea33 --- /dev/null +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -0,0 +1,231 @@ + + +# M3 Org Lens: Why EasyCLA Companies Are Invisible, and What Makes Them Visible + +**Status**: Updated after the 2026-09-10 architecture call · data re-measured 2026-09-10 +**Owner**: Michal (engineering) +**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [`specs/044-lfx-v2-cla-service/`](https://github.com/linuxfoundation/lfx-self-serve/tree/main/specs/044-lfx-v2-cla-service) rev 5 (in `lfx-self-serve`, not this repo) + +**The problem in one line**: the Self Serve Org Lens lists LF **member** organizations, most EasyCLA customers are **not** members, so most CLA managers would open Self Serve and see nothing. + +--- + +## 1. Summary + +Two independent gates keep EasyCLA companies out of the Org Lens, and they need different fixes: + +| | Gate | Affected | Fix | +|---|---|---|---| +| **1a** | No account in the B2B Salesforce org at all | 1,633 of 2,995 (55%) | Ingest accounts (Eric's proposal — needs sales-ops approval) | +| **1b** | Account exists but has no Membership Asset | 314 | Widen the member-service `b2b_org` predicate | +| **2** | User holds no OpenFGA grant on the org | all CLA managers | CLA FGA types + tuples projected from `signature_acl` | + +Gate 1a is the critical path: it depends on a sales-ops approval nobody on this team controls. Gates 1b and 2 are engineering work inside LFX. + +**This document reverses a previously approved decision.** See §6 — the M5 deferral of CLA-in-OpenFGA is recorded in at least five documents, one of them an architecture-review-approved proposal. + +--- + +## 2. The gap, quantified + +Measured in Snowflake against prod on 2026-09-10. member-service reads the **B2B Salesforce org** (18,231 accounts, carved out of the old platform Salesforce with record IDs preserved) — not the old platform org (100,592 accounts) that EasyCLA's `company_external_id` values point at. + +### 2.1 What EasyCLA actually stores + +EasyCLA has 3,705 company rows; 3,537 carry a non-empty `company_external_id`, collapsing to 3,531 distinct values. **Those 3,531 are not all Salesforce IDs:** + +| `company_external_id` shape | Distinct | In B2B org | Live in old platform org | +|---|---:|---:|---:| +| `001…` — a real Salesforce ID | **2,995** | 1,362 | 2,920 | +| `lf…` — an LFX-generated Org Service ID | **530** | 0 | 520 | +| other / malformed | 6 | 0 | 3 | +| *(empty — 168 rows, excluded above)* | — | — | — | + +The 530 `lf`-prefixed values are the Org Service's own 18-character identifiers, [deliberately shaped to be drop-in compatible with a Salesforce ID](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md). **They are not Salesforce IDs and can never match a B2B account by ID** — none of the 530 do. Any analysis that treats `company_external_id` as an SFID overcounts the population by 536. + +> **Consequence for the ingest.** These 530 companies are not a lookup failure to be remapped — there is no Salesforce record to find. They need an account **created** (or domain-matched), exactly like the missing 1,633, and they are invisible to any SFID-keyed remediation. They are, however, in scope for Eric's domain-based sizing, because 520 of them still resolve to a live Org Service organization carrying a domain. + +### 2.2 Visibility, among the 2,995 with a real SFID + +| Metric | Count | Share | +|---|---:|---:| +| Distinct real SFIDs in EasyCLA | 2,995 | 100% | +| …with an account in the B2B org | 1,362 | 45% | +| ……member → **visible in Self Serve today** | **1,048** | **35%** | +| ……present but non-member (gate 1b) | 314 | 10% | +| …with **no B2B-org account** (gate 1a) | **1,633** | **55%** | +| ……still a live account in the old platform org | 1,559 | | +| ……dangling — resolves nowhere | 74 | | + +Restricted to companies with an **active signed CCLA** — the population that actually matters for M3: + +| Metric | Count | Share | +|---|---:|---:| +| Orgs with an active signed CCLA (real SFID) | 1,948 | 100% | +| …visible today (member in B2B org) | 808 | 41% | +| …invisible — non-member 180, absent 960 | **1,140** | **59%** | + +Membership uses the exact gate member-service applies: a B2B-org `Account` having an `Asset` whose `Product2.Family = 'Membership'` (8,064 of 18,231 accounts qualify). + +> **Basis note.** All counts are distinct IDs, not company rows — 3,537 rows collapse to 3,531 values, the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056). B2B-org matching compares the first 15 characters, since Salesforce 15- and 18-character IDs denote the same record. Totals drift by a few rows between syncs as DynamoDB grows; percentages are stable. + +### 2.3 How this relates to Eric's sizing + +Eric's [ingest proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) sizes the same problem a different way, and both results are correct — they answer different questions: + +| | This document | Eric's proposal | +|---|---|---| +| **Question** | Which EasyCLA orgs can member-service resolve *today*? | How many Salesforce accounts must be *created*? | +| **Population** | 2,995 real SFIDs | 3,693 CCLA companies, less 258 with a missing/broken org reference → 3,435 | +| **Match key** | stored SFID present **by ID** in the B2B org | normalized **domain**, via the Org Service org (EasyCLA stores no domain) | +| **Already covered** | 1,362 | 1,725 | +| **Not covered** | 1,633 | 1,710 → **1,709 distinct orgs, +9.4%** on 18,181 | + +The two "not covered" figures are close, but they are **not the same set**, and the difference is the operationally important part. Measured directly on the same rows: + +- **374** orgs are absent from the B2B org by ID yet **domain-match an existing B2B account**. These need **linking**, not creating — and they still need an ID remap, because the account they link to has a different ID than the one EasyCLA stores. +- **11** orgs are present by ID but have no domain match, so Eric's method counts them as needing an account they already have. A negligible error bound on his figure, noted for completeness. +- Eric's population includes the `lf`-shaped and broken-reference companies that §2.1 separates out, which is why his totals run higher. + +**Steady state after the backfill**: ~362 new CCLA companies/year, of which ~196 (~16/month) need a new account — flat over three years. + +> **Caveat worth raising with Eric.** The 1,725 "already maps" and the 374 links above are *inferred from domain equality*, not verified identity. Shared or reused domains (subsidiaries, acquisitions, ISP-hosted sites) can mislink. Eric's staged review covers the records he *creates*; the links get no equivalent review pass, yet a mislink silently points a signed CCLA at the wrong company. Worth a review gate on the link set, not just the create set. + +--- + +## 3. Why they are invisible — the two gates + +```mermaid +flowchart LR + OLD[("Old platform Salesforce
100,592 accounts — where
EasyCLA IDs point")] -.->|"B2C decouple carved out
18.2k accounts, IDs preserved"| SF + SF["B2B Salesforce org
18,231 accounts"] -->|"gate 1b: has Membership Asset
(8,064 accounts)"| MS["member-service"] + MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] + MS -->|"writer / auditor tuples"| FGA["OpenFGA"] + QS -->|"org list"| SS["Self Serve
Org Lens"] + FGA -->|"gate 2: user must hold
a grant on the org"| SS + CLA[("EasyCLA DynamoDB
2,995 real SFIDs
+ 536 non-Salesforce IDs")] -.->|"gate 1a: 1,633 have no
B2B-org account"| SF +``` + +1. **The org record does not exist**, in two layers. **(1a)** 55% of EasyCLA orgs with a real SFID have no account in the B2B Salesforce org — they were left behind in the old platform org during the B2C decouple. No predicate change can surface them; the accounts must be ingested. **(1b)** The 314 that do exist fail the Membership-Asset gate — predicate widening covers exactly these, and nothing else. +2. **The user holds no grant.** Org Lens eligibility is an OpenFGA relation on `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS and Org Service scopes; OpenFGA knows nothing about them. + +> **ID remapping is a required work item.** Salesforce cannot create a record with a chosen ID, so every newly created account gets a **new SFID**. Combined with the 374 domain-links and the 530 `lf`-shaped IDs, EasyCLA's stored `company_external_id` will not resolve for most of the affected population. The ingest must produce an old-ID → new-ID map that EasyCLA (or the CLA service's mapping store) applies. Only the 1,362 already present in the B2B org carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 4](#5-open-items). + +--- + +## 4. Direction agreed on the 2026-09-10 architecture call + +**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** No separate "EasyCLA organization" entity, no new B2C org type, no parallel org catalogue. A CCLA attaches to a B2B account the same way a membership does. + +Eric's written proposal — [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16) — is published and going to sales ops. **That approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (acknowledged on the call). + +### 4.1 What this document depends on from that proposal + +1. **CCLA signing is a recognized B2B onboarding path** — structurally parallel to member enrollment, minus the financial relationship. Consistent with Eric's earlier [organization-decoupling proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2024-12%20Decoupling%20orgs%20and%20users/README.md#a-proposal-for-organization-decoupling) (2024-12), where B2B org records are created only when a user starts a B2B flow "like Member Enrollment or Corporate CLA", and the parallel LFX org database is sunset rather than extended. +2. **LFX creates the ~1,709 accounts**, staged and reviewable in tranches; Sales Ops approves the records and field semantics. `IsMember__c` stays **false** — which is precisely why the predicate widening in §4.2 is still required: ingested accounts would otherwise keep failing the Membership-Asset gate. +3. **The 258 companies with a missing or broken org reference are excluded** from the ingest and remediated in EasyCLA (§6 prerequisites). Since EasyCLA stores no company domain, the one credible signal is the CLA managers' email domains — a per-record exercise, not a query. +4. **Future CCLA onboarding routes through Salesforce account creation at signing time**, so the backfill is one-time. End-user UX does not change. *Which* service creates the account — a membership-free Apex endpoint that keeps matching policy in Salesforce, or LFX calling the standard Account API with its own domain dedupe — is an open governance question in Eric's proposal and does not need resolving here. + +> **Mechanical note.** member-service's existing `create-b2b-org` (`POST /b2b_orgs`) **registers** an Account that already exists in Salesforce; it does not create one, and it performs no duplicate check ([TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md) §5). That is the same operation as `b2b_org_ensure` below — account creation happens upstream of it. + +### 4.2 The technical shape, converging with spec 044 rev 5 + +```mermaid +flowchart LR + subgraph v1 ["EasyCLA v1 (AWS)"] + DDB[("DynamoDB
companies + signatures
(signature_acl = managers)")] + V4["EasyCLA v4 API"] + end + subgraph platform ["LFX One platform"] + SH["lfx-v1-sync-helper
(streams + Meltano)"] + KV["NATS KV
cla-v1-objects"] + MS["member-service
predicate: membership
OR CLA-referenced"] + FGA["OpenFGA
cla_group / cla_ccla /
cla_ecla / cla_icla"] + QS["query-service"] + end + DDB --> SH --> KV + KV -->|"projector: ACL diff →
manager / signatory tuples"| FGA + KV -.->|"b2b_org_ensure for
unknown accounts"| MS + MS -->|"b2b_org docs + reindex"| QS + SS["Self Serve"] -->|"org list + selector union
(org read OR cla_ccla manager)"| QS + SS -->|"/access-check"| FGA + SS -->|"CLA tabs: bridge (v4 via gateway)
until parity gate flips flags"| V4 +``` + +- **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. +- **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. +- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a fresh backfill pass over CLA roles is required (spec 044 story H). + +> **Open question — signatories.** Spec 044 models `cla_ccla` with both `manager` and `signatory`, but the selector union (in rev 5 and above) admits only `manager`. A user holding `signatory` and no `b2b_org` grant could not enter the lens at all, which does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the union includes `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. For Luis and Eric; not decided here. + +### 4.3 What changed versus the pre-call version + +| Pre-call proposal | Now | +|---|---| +| New `cla_manager` relation on `b2b_org` | Superseded by dedicated CLA FGA types (`cla_ccla#manager` etc.), manager derived from `signature_acl` | +| Admit EasyCLA orgs "tagged non-member" | Sharpened: they become real B2B accounts (sales-ops approval pending), and the predicate widens | +| CLA tabs call v4 via gateway; no replication in M3 | Converges with spec 044's rollout: pages run on the bridge behind per-env flags, flipping to the CLA service only at zero parity differences | + +--- + +## 5. Open items + +1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16); Eric is opening the conversation. Heather Willson is out the week of 2026-09-14, so her involvement follows. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. +2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and the member-service PR. This review is also where the reversal in §6 should be formally recorded. +3. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. +4. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. +5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. + +--- + +## 6. Documents this supersedes + +The 2026-09-10 call reversed the position that CLA object types enter the platform authorization model only at M5, gating M3 on the ACS permission bridge instead. That position is recorded in **five** documents plus an epic, and all of them now contradict this one: + +| Document | Where | +|---|---| +| [`architecture-proposal.md`](architecture-proposal.md) | **P2** (line 90) — "Roles: bridge, don't migrate. No CLA object types in OpenFGA before M5"; also lines 57, 105, 110 | +| [`role-mapping-feasibility.md`](role-mapping-feasibility.md) | §0 (line 25), option B rejection (line 202), option C (line 204) | +| [`00-overview-fable.md`](../../specs/001-easycla-ss-integration-fable/00-overview-fable.md) | §3 item 3 (line 64); also lines 50, 89 | +| [`03-milestone-ccla-org-lens-fable.md`](../../specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md) | line 37 — "A for M3, B deferred into M5, C rejected" | +| [`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) | line 223 — "deferred to M5 scope… no CLA object types in the platform authorization model" | +| Epic [lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) | same statement | + +**P2 is not a passing mention — it is an architecture-review-approved proposal** (Eric, with the endpoint-deprecation risk closed 2026-07-31, ARCH-406). Reversing it means reopening that approval at the spec-044 ADR review (open item 2), not merely editing prose. Until these are updated, two incompatible authorization architectures are documented side by side. + +Two further documents describe behavior that changes but are not "superseded" in the same sense: + +- [`docs/M3_ORG_LENS_API.md`](../M3_ORG_LENS_API.md) documents per-endpoint **ACS scope** authorization for shipped endpoints. If CLA FGA types land in M3, this describes live behavior that changes — arguably a higher-stakes update than the planning specs. +- `spec.md` FR-032 pins role-assignment consistency to "the system of record used by EasyCLA's enforcement" (ACS), which an M3 FGA move puts in tension. + +**What does not change**: FGA governs **lens entry and UI gating**; EasyCLA v4 via ACS remains the **enforcement** point for every write through M3. Two layers, not two systems of record — but see open item 5 for the parity requirement that makes this safe. + +--- + +## 7. Prerequisites already tracked + +Data cleanup needed regardless of the outcome ([parent story lfx-self-serve#2043](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): + +- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — companies with a missing or invalid SFID are unreachable under any design. This overlaps Eric's 258 excluded companies (168 empty plus 90 dangling references, counted against Org Service — hence the different total) and, more importantly, the **530 `lf`-shaped IDs** in §2.1, which are not malformed but simply are not Salesforce IDs. The ticket's scope should be checked against both sets. +- [lfx-self-serve#2055](https://github.com/linuxfoundation/lfx-self-serve/issues/2055) — legacy `POST /v1/company` still creates companies with no Salesforce link. Eric's proposal point 4 (account creation at signing time) makes closing this more urgent: that path would bypass the new flow entirely and keep generating the problem this document describes. +- [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) — duplicate company rows per SFID break resolution. + +--- + +## Appendix: reproducing the numbers + +All figures in §2 come from Snowflake, measured 2026-09-10, read-only: + +| Source | Table | +|---|---| +| B2B Salesforce org (what member-service reads) | `FIVETRAN_INGEST.SALESFORCE.ACCOUNT` | +| Membership gate | `…SALESFORCE.ASSET` ⋈ `…SALESFORCE.PRODUCT_2` on `FAMILY = 'Membership'` | +| Old platform org (where EasyCLA IDs point) | `FIVETRAN_INGEST.SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` | +| EasyCLA companies | `FIVETRAN_INGEST.DYNAMODB_PRODUCT_US_EAST_1.CLA_PROD_COMPANIES` | +| EasyCLA signatures | `…DYNAMODB_PRODUCT_US_EAST_1.CLA_PROD_SIGNATURES` | + +Rules applied throughout: exclude `IS_DELETED`/`ISDELETED` and `_FIVETRAN_DELETED`; join B2B IDs on `LEFT(id, 15)`; treat `company_external_id` as a real SFID only when it matches `001%`. Active CCLA means a `CLA_PROD_SIGNATURES` row with `signature_type = 'ccla'`, `signature_reference_type = 'company'`, and both `signature_signed` and `signature_approved` true. + +Eric's domain-matching figures are reproducible from [his scripts](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend/scripts); the 374/11 overlap figures in §2.3 come from running his classification and the SFID-presence test over the same rows in one query. diff --git a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md b/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md deleted file mode 100644 index 7cc5552e3..000000000 --- a/specs/001-easycla-ss-integration-fable/06-m3-org-visibility-and-access.md +++ /dev/null @@ -1,127 +0,0 @@ - - - -# M3: EasyCLA Orgs in the Self Serve Org Lens — Visibility & Access - -**Status:** Updated after the 2026-09-10 architecture call · aligned with [`specs/044-lfx-v2-cla-service/`](https://github.com/linuxfoundation/lfx-self-serve/tree/main/specs/044-lfx-v2-cla-service) (rev 5, in the `lfx-self-serve` repo — not in this one) -**Problem:** The Self Serve Org Lens only shows LF **member** organizations. Most EasyCLA customers are **not** members — so most CLA managers would open Self Serve and see nothing. - -> **Supersedes the "CLA-in-OpenFGA is M5" decision.** Three companion documents in this repo still record the earlier position that CLA object types enter the platform authorization model only at M5, and gate M3 on the ACS permission bridge instead: [`00-overview-fable.md`](00-overview-fable.md) (§3.3), [`03-milestone-ccla-org-lens-fable.md`](03-milestone-ccla-org-lens-fable.md) (option A recommended, "B deferred into M5"), and [`spec.md`](spec.md) ("Modeling CLA roles natively in the platform's fine-grained-authorization system is deferred to M5 scope"). The epic [lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) carries the same statement. The 2026-09-10 architecture call reversed this: the CLA FGA types are needed **in M3**. Those documents and the epic must be updated together with this one — until they are, two incompatible authorization architectures are documented side by side. -> -> Note what does **not** change: FGA governs **lens entry and UI gating**; EasyCLA v4 (via ACS) remains the **enforcement** point for every write through M3. That is two layers, not two systems of record — but see the parity requirement in open item 5. - -## The gap, quantified (prod data, Snowflake) - -member-service reads the **B2B Salesforce org** (~18.2k accounts, carved out of the old platform Salesforce with record IDs preserved) — not the old platform org (~100k accounts) that EasyCLA's `company_external_id` values point at. Measured against the B2B org: - -| Metric | Count | -|---|---| -| EasyCLA companies with a Salesforce ID (SFID) | 3,531 | -| …with an account in the **B2B Salesforce org** | 1,362 (39%) | -| ……of those, member → **visible in Self Serve today** | **1,048 (30%)** | -| ……present in B2B org but non-member | 314 | -| …with **no account in the B2B org at all** | **2,169 (61%)** | -| ……of those, still a live account in the old platform org | 2,082 | -| Orgs with an **active signed CCLA** | 2,278 | -| …visible today (member in B2B org) | 808 (35%) | -| …invisible: present-but-non-member / absent from B2B org | **180 / 1,290 (65% combined)** | - -All counts above are **distinct Salesforce IDs**, not EasyCLA company rows: 3,537 company rows with a well-formed SFID collapse to 3,531 distinct SFIDs (the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) accounts for the difference). Membership uses the exact gate member-service applies: B2B-org `Account` having an `Asset` with `Product2.Family = 'Membership'` (8,065 accounts qualify). - -**Reconciled with Eric's published sizing.** Eric's proposal is now published: [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) (method and reproduction in [TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16)). His numbers and the table above measure different populations with different matching keys — both hold: - -| | This doc | Eric's proposal | -|---|---|---| -| Population | 3,531 distinct stored SFIDs | 3,693 CCLA companies; 258 with a missing/broken org reference excluded → 3,435 | -| Matching key | stored SFID **present by ID** in the B2B org | normalized **domain** (EasyCLA → Org Service org → domain; EasyCLA itself stores no domain) against all 18,181 B2B accounts | -| Matches an existing B2B account | 1,362 | 1,725 | -| No B2B account | 2,169 | 1,710 → **1,709 distinct orgs to create** (+9.4% on 18,181) | - -The gap between 2,169 and 1,709 is a few hundred orgs whose stored SFID is absent from the B2B org but whose **domain matches an existing B2B account** — those need **linking** to the existing account, not a new record. Consequence for EasyCLA: the SFID remap (open item 4) covers the domain-linked orgs as well as the newly created ones — in both cases the stored `company_external_id` differs from the final B2B account ID. Steady state after the backfill: ~362 new CCLA companies/yr, of which ~196 (~16/month) need a new account. - -## Why they are invisible — two independent gates - -```mermaid -flowchart LR - OLD[("Old platform Salesforce
(~100k accounts — where
EasyCLA SFIDs point)")] -.->|"B2C decouple carved out
18.2k accounts, IDs preserved"| SF - SF["B2B Salesforce org
(~18.2k accounts)"] -->|"gate 1b: has Membership Asset
(8,065 accounts)"| MS["member-service"] - MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] - MS -->|"writer / auditor tuples"| FGA["OpenFGA"] - QS -->|"org list"| SS["Self Serve
Org Lens"] - FGA -->|"gate 2: user must hold
a grant on the org"| SS - CLA[("EasyCLA DynamoDB
3,531 orgs")] -.->|"gate 1a: 2,169 orgs have no
B2B-org account at all"| SF -``` - -1. **The org record does not exist** — in two layers. (1a) 61% of EasyCLA orgs have **no account in the B2B Salesforce org** — they were left behind in the old platform org during the B2C decouple. No member-service predicate change can surface them; the accounts must be ingested (Eric's proposal). (1b) The 314 that do exist fail the Membership-Asset gate — the predicate widening covers exactly these. -2. **The user has no grant.** Org Lens eligibility is an OpenFGA relation resolved against `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS/Org Service scopes — OpenFGA knows nothing about them. - -> **ID remapping consequence:** accounts newly created in the B2B org get **new SFIDs** (Salesforce cannot create a record with a chosen ID). For those 2,169 orgs — the ~1,709 created *and* the domain-linked remainder — EasyCLA's stored `company_external_id` will no longer resolve; the ingest must produce an old-ID → new-ID map, and EasyCLA (or the CLA service's mapping store) must apply it. The 1,362 already present carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism: after import, each CCLA organization gets a foreign key to its Salesforce Account ID and **participates in future account merges** so the key follows the surviving record — in his words, "the part that does not exist today". Where that key lives and who applies it is open item 4. - -## Direction agreed on the 2026-09-10 architecture call - -**EasyCLA companies are B2B engagements — they become real Salesforce B2B accounts.** There will be no separate "EasyCLA organization" entity, no new B2C org type, and no parallel org catalogue. A CCLA attaches to a B2B account, the same way membership does. Eric's written proposal — [Consolidate B2B backend, ingest EasyCLA companies as Salesforce accounts](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md) — is now published and going to sales ops (Mindy) for approval. **That approval is the critical-path dependency for M3.** If sales ops pushes back, a different approach is needed (explicitly acknowledged on the call). - -Points of Eric's proposal that this document depends on: - -1. **CCLA signing is a recognized B2B onboarding path** — structurally parallel to member enrollment, minus the financial relationship. This is consistent with his earlier [organization-decoupling proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2024-12%20Decoupling%20orgs%20and%20users/README.md#a-proposal-for-organization-decoupling) (2024-12): B2B org records are created only when a user initiates a B2B flow "like Member Enrollment or Corporate CLA", and the parallel LFX org database keeps being sunset, not extended. -2. **LFX creates the ~1,709 accounts** (staged, reviewable, in tranches); Sales Ops approves the records and field semantics. `IsMember__c` stays **false** — which is exactly why the member-service predicate widening below remains necessary; the ingested accounts would otherwise still fail the Membership-Asset gate. -3. **The 258 companies with a missing/broken org reference are excluded** and remediated in EasyCLA (see prerequisites below) — the likeliest signal being the CLA managers' email domains, since EasyCLA stores no company domain. -4. **Future CCLA onboarding routes through Salesforce account creation at signing time** (end-user UX unchanged). *Which* service creates the account — a new membership-free Apex endpoint keeping matching rules in Salesforce, or LFX calling the standard Account API with its own domain dedupe — is an open governance question for Sales Ops in the proposal, not something this document needs to resolve. - -One mechanical note from [TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md) §5: member-service's existing `create-b2b-org` (`POST /b2b_orgs`) **registers** an Account that already exists in Salesforce — it does not create one, and performs no duplicate check. That is the same operation as the `b2b_org_ensure` request below; actual account creation happens upstream, on whichever path point 4 resolves to. - -The technical shape converges with the CLA-service plan (spec 044, rev 5): - -```mermaid -flowchart LR - subgraph v1 ["EasyCLA v1 (AWS)"] - DDB[("DynamoDB
companies + signatures
(signature_acl = managers)")] - V4["EasyCLA v4 API"] - end - subgraph platform ["LFX One platform"] - SH["lfx-v1-sync-helper
(streams + Meltano)"] - KV["NATS KV
cla-v1-objects"] - MS["member-service
predicate: membership
OR CLA-referenced"] - FGA["OpenFGA
cla_group / cla_ccla /
cla_ecla / cla_icla"] - QS["query-service"] - end - DDB --> SH --> KV - KV -->|"projector: ACL diff →
manager / signatory tuples"| FGA - KV -.->|"b2b_org_ensure for
unknown accounts"| MS - MS -->|"b2b_org docs + reindex"| QS - SS["Self Serve"] -->|"org list + selector union
(org read OR cla_ccla manager)"| QS - SS -->|"/access-check"| FGA - SS -->|"CLA tabs: bridge (v4 via gateway)
until parity gate flips flags"| V4 -``` - -- **Org catalogue:** member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus a `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand; full `b2b_org` reindex afterwards. member-service remains the one Salesforce org service. -- **Permissions:** new FGA types per spec 044 — `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla` — confirmed on the call as needed **in this milestone** regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. -- **Lens entry:** the org selector becomes a union — org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla` — with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - > **Open question — signatories.** Spec 044 models `cla_ccla` with both `manager` and `signatory`, but the selector union above (and rev 5's) admits only `manager`. A user who holds `signatory` and no `b2b_org` grant would be unable to enter the lens at all, which does not square with M3's signatory flow ([`spec.md`](spec.md) FR-030/FR-031). Either the union must include `signatory` — with relation-specific screen permissions so it does not confer manager or org-wide access — or the signatory flow must enter by some other route. Raised for Luis and Eric; not decided here. -- **Console cutover:** hard cut, no parallel operation of the Corporate Console and the Org Lens (agreed on the call). That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a new backfill pass over CLA roles is required (spec 044 story H). - -### What changed vs. the earlier version of this proposal - -| Earlier proposal (this doc, pre-call) | Now | -|---|---| -| New `cla_manager` relation on `b2b_org` | Superseded: dedicated CLA FGA types (`cla_ccla#manager` etc.), manager from `signature_acl` | -| Admit EasyCLA orgs "tagged non-member" | Confirmed and sharpened: they become real B2B accounts (sales-ops approval pending); member-service predicate widened | -| CLA tabs call v4 via gateway; no replication in M3 | Converges with spec 044's own rollout: pages run on the bridge behind per-env flags and flip to the CLA service only at zero parity differences | - -## Open items - -1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published ([README](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md), tracked in [linuxfoundation/lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16)) and Eric is starting the sales-ops conversation with Mindy; Heather Willson is out the week of 2026-09-14, so her involvement follows after. Note the ingest is not only a catalogue change: 61% of EasyCLA orgs need an account **created or domain-linked** in the B2B org (→ EasyCLA `company_external_id` remap required), and only the 314 already-present non-member accounts are covered by predicate widening alone. -2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and member-service PR. -3. **M3 sequencing** — the minimum for M3 is the catalogue change, the FGA model + `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with the CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind the parity-gated flags and is not an M3 dependency. - -4. **SFID remap ordering, and its acceptance check.** The old-ID → new-ID map is not just an artifact to produce — it has a required position in the sequence. For any org whose account is newly created **or domain-linked to an existing account**, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map fails silently: the org simply stays invisible with no signal. Eric's proposal supplies the ongoing half of the mechanism (a foreign key on each CCLA organization to its Salesforce Account ID, updated on account merges), but ownership of producing and applying the map — and whether that key **is** EasyCLA's `company_external_id`, a new EasyCLA field, or the spec-044 mapping store — is unassigned and needs deciding. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID, and the org appears in the lens. - -5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. These are different inputs, so they can disagree in both directions — a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; what is still needed is the required parity behavior at cutover (which side wins, and what the acceptable divergence is when the flags flip). - -## Prerequisites already tracked - -Data cleanup, needed regardless of the outcome ([parent story](https://github.com/linuxfoundation/lfx-self-serve/issues/2043)): - -- [lfx-self-serve#2054](https://github.com/linuxfoundation/lfx-self-serve/issues/2054) — 230 companies with missing/invalid SFID are unreachable under any design. This substantially overlaps Eric's 258 excluded companies (168 empty + 90 dangling references; counted against Org Service, hence the different total). His proposal excludes them from the ingest and puts remediation on EasyCLA; the one credible signal is the CLA managers' email domains (`company_acl` / `company_manager_id`), since EasyCLA stores no company domain — a per-record exercise, not a query. -- [lfx-self-serve#2055](https://github.com/linuxfoundation/lfx-self-serve/issues/2055) — legacy `POST /v1/company` still creates companies with no Salesforce link -- [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056) — duplicate company rows per SFID break resolution From 8dbd8ef10f0f993f479e3d24de79ec9b00a0dfc5 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 22:19:23 -0700 Subject: [PATCH 07/23] docs: remove broken spec 044 link The URL added in 400e1016a pointed at lfx-self-serve/tree/main/specs/044-lfx-v2-cla-service, which 404s -- that repo's specs/ directory on main contains only 001, 008 and 009. The path was inferred when the reference was linkified, not verified. I could not establish where spec 044 actually lives (no matching branch or PR in lfx-self-serve; no lfx-v2-cla-service repo accessible), so the reference is left as plain text rather than replaced with another guess. Worth asking Luis for the real location. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index fd470ea33..d00ea1f21 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> **Status**: Updated after the 2026-09-10 architecture call · data re-measured 2026-09-10 **Owner**: Michal (engineering) -**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [`specs/044-lfx-v2-cla-service/`](https://github.com/linuxfoundation/lfx-self-serve/tree/main/specs/044-lfx-v2-cla-service) rev 5 (in `lfx-self-serve`, not this repo) +**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · spec 044 (`lfx-v2-cla-service`) rev 5 — Luis's CLA-service plan, not in this repo **The problem in one line**: the Self Serve Org Lens lists LF **member** organizations, most EasyCLA customers are **not** members, so most CLA managers would open Self Serve and see nothing. From 928e40e5a2ea04cffab45239864a672304642609 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 22:39:34 -0700 Subject: [PATCH 08/23] docs: cite Luis's CLA-service plan and correct spec 044 claims Spec 044 is real: the spec package is specs/044-lfx-v2-cla-service/ on branch 044-lfx-v2-cla-service, in a repo outside this clone's reach -- which is why the URL removed in 0bc12f2d2 could not be verified. The reachable source is Luis's recap doc (rev 5, 2026-09-10), now cited. It corroborates every spec-044 claim this document makes: the four FGA types and their relations, manager-from-signature_acl with ACS as a dry-run drift report only, the dedicated cla-v1-objects bucket, the four ADRs, b2b_org_ensure, predicate widening, and the parity-gated per-env flag rollout. Three corrections fall out of reading it: - The signatory open question was framed imprecisely. Spec 044 computes cla_ccla#auditor as "manager or signatory or ...", and the agreement's query-plane gate is #auditor, so a signatory can read the agreement. The gap is narrower and sits in the org selector, whose union is manager-only: a signatory with no b2b_org grant has no organization to select, so never reaches data they are authorized to read. Still open, now stated accurately. - "Stories A, B, C9, D" was wrong. The epic uses lettered sections with numbered items, so C9 is not a story ID. The M3 minimum is items 5, 9, 10 and 13. - "Story H" is section H item 22, the --backfill-acs --roles cla --dry-run drift report. Adds two things the recap surfaces: - This document closes spike item #4, "catalogue-gap count" -- rev 5 names the same membership-asset boundary as its biggest dependency and defers the sizing to that spike. Noted in the summary, with the caveat that the naive query overstates it by 536. - A new 4.4 recording the five second-order effects of admitting ~1,600 non-member orgs, none of which have owners: the sync-helper company- admin import deciding whether every CLA requester becomes an org admin, staff blanket read extending to non-member orgs, the missing CLA-only organization state, volume, and -- compounding the SFID remap problem -- that a full rebuild silently drops onboarded companies unless "referenced by a CLA" is stored durably. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-org-visibility.md | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index d00ea1f21..33f1dc36f 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> **Status**: Updated after the 2026-09-10 architecture call · data re-measured 2026-09-10 **Owner**: Michal (engineering) -**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · spec 044 (`lfx-v2-cla-service`) rev 5 — Luis's CLA-service plan, not in this repo +**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [EasyCLA → LFX One recap](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) (Luis, rev 5, 2026-09-10) — the CLA-service plan, spec package `specs/044-lfx-v2-cla-service/` on branch `044-lfx-v2-cla-service` **The problem in one line**: the Self Serve Org Lens lists LF **member** organizations, most EasyCLA customers are **not** members, so most CLA managers would open Self Serve and see nothing. @@ -25,6 +25,8 @@ Gate 1a is the critical path: it depends on a sales-ops approval nobody on this **This document reverses a previously approved decision.** See §6 — the M5 deferral of CLA-in-OpenFGA is recorded in at least five documents, one of them an architecture-review-approved proposal. +**It also closes an open spike in the CLA-service plan.** Spec 044 rev 5 names the same membership-asset boundary as its "biggest dependency" and defers the sizing to spike item #4, *"catalogue-gap count"* — "CCLA-holding companies whose `company_external_id` is absent from the membership-asset accounts". §2 is that count, with the caveat that the naive form of the query overstates it by 536 (see §2.1). + --- ## 2. The gap, quantified @@ -156,9 +158,9 @@ flowchart LR - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. - **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. -- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a fresh backfill pass over CLA roles is required (spec 044 story H). +- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a fresh backfill pass over CLA roles is required (spec 044 section H, item 22 — the `--backfill-acs --roles cla --dry-run` drift report). -> **Open question — signatories.** Spec 044 models `cla_ccla` with both `manager` and `signatory`, but the selector union (in rev 5 and above) admits only `manager`. A user holding `signatory` and no `b2b_org` grant could not enter the lens at all, which does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the union includes `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. For Luis and Eric; not decided here. +> **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. ### 4.3 What changed versus the pre-call version @@ -168,13 +170,27 @@ flowchart LR | Admit EasyCLA orgs "tagged non-member" | Sharpened: they become real B2B accounts (sales-ops approval pending), and the predicate widens | | CLA tabs call v4 via gateway; no replication in M3 | Converges with spec 044's rollout: pages run on the bridge behind per-env flags, flipping to the CLA service only at zero parity differences | +### 4.4 Second-order effects of admitting these orgs + +Admitting ~1,600 non-member companies to the org catalogue changes more than the catalogue. Spec 044's companion note ["What changes if every CLA-signing company becomes a real organization"](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) raises five, none of which have owners yet. They are consequences of the decision this document argues for, so they belong in its scope: + +| Effect | Why it matters | Needs deciding | +|---|---|---| +| **Company admins appear automatically** | EasyCLA records whoever creates a company as its administrator. The v1-sync-helper already imports company admins as **org admins** — but filters to member companies. Widen the catalogue and that filter decides whether every CLA requester becomes an org admin who can edit People and key contacts. | Import as admins, as viewers, or not at all — relying on the CLA-manager relation only. Product/legal, not technical. Work item sits in the sync helper. | +| **Staff read extends to them** | LF staff hold blanket read on every org; the new companies inherit it, exposing thousands of non-member companies and their CLA data. The blanket grant was reviewed for member companies only. | Re-confirm the grant covers non-member orgs; record it in the decision. | +| **"CLA-only organization" state** | Memberships, key contacts, ROI, meetings are all empty for a never-member company. Rev 5 covers the *user* who enters as a CLA manager, not the *company kind* — so even a full admin lands on empty pages. | An org-level CLA-only marker: hide or explain the membership sections. | +| **Volume** | The org list grows by roughly 9.4%. Affects the staff selector (may need search-first), full-rebuild time, and the Salesforce API budget. | Measure rebuild time before launch; adjust the selector if needed. | +| **Making onboarding stick** | `b2b_org_ensure` is a one-time event. A full rebuild starts from Salesforce again and **drops these companies** unless "referenced by a CLA" is stored durably. | Choose the durable signal — a field on the Salesforce account set by EasyCLA, or a marker LFX keeps — as part of the decision. | + +The last one compounds the remap problem in [open item 4](#5-open-items): both are silent failures surfacing only as an org that quietly stops appearing. + --- ## 5. Open items 1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16); Eric is opening the conversation. Heather Willson is out the week of 2026-09-14, so her involvement follows. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. -2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` bucket, read-plane-first for an external system of record, catalogue predicate + selector rule. Gates the FGA model bump and the member-service PR. This review is also where the reversal in §6 should be formally recorded. -3. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union (spec 044 stories A, B, C9, D), with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. +2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. +3. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. 4. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. 5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. From 77e69accec3ff8a76f703cb5f4b069e996da7249 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 10 Sep 2026 22:49:16 -0700 Subject: [PATCH 09/23] docs(review): address PR #5210 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: - docs/easycla-ss-migration/m3-org-visibility.md: gate 2 in the §1 table said "all CLA managers" were blocked; managers who independently hold a b2b_org writer/auditor grant already pass gate 2. Narrowed to managers without an independent grant. - docs/easycla-ss-migration/m3-org-visibility.md: §4.2 contradicted itself on the tuple backfill source — the permissions bullet makes signature_acl authoritative and limits ACS to a dry-run drift report, but the cutover bullet called for an "ACS->FGA backfill pass" for newly admitted accounts. Backfilling grants from ACS can project stale or divergent roles. Restated: initial backfill and ongoing projection both come from signature_acl (spec 044 item 10), with ACS read-only in the item 22 drift report. Resolves 8 review threads (2 fixed, 1 declined, 5 obsolete or already fixed). Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 33f1dc36f..8b600313b 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -19,7 +19,7 @@ Two independent gates keep EasyCLA companies out of the Org Lens, and they need |---|---|---|---| | **1a** | No account in the B2B Salesforce org at all | 1,633 of 2,995 (55%) | Ingest accounts (Eric's proposal — needs sales-ops approval) | | **1b** | Account exists but has no Membership Asset | 314 | Widen the member-service `b2b_org` predicate | -| **2** | User holds no OpenFGA grant on the org | all CLA managers | CLA FGA types + tuples projected from `signature_acl` | +| **2** | User holds no OpenFGA grant on the org | every CLA manager without an independent `b2b_org` grant | CLA FGA types + tuples projected from `signature_acl` | Gate 1a is the critical path: it depends on a sales-ops approval nobody on this team controls. Gates 1b and 2 are engineering work inside LFX. @@ -158,7 +158,7 @@ flowchart LR - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. - **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. -- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync; the drift report suffices. The earlier one-time ACS→FGA backfill predates the newly admitted accounts, so a fresh backfill pass over CLA roles is required (spec 044 section H, item 22 — the `--backfill-acs --roles cla --dry-run` drift report). +- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. > **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. From ad96da11348a807e46b826505c7232fe7faa5481 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 11 Sep 2026 14:52:58 -0700 Subject: [PATCH 10/23] docs: correct the membership gate and record the entry-point question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incorporates the 2026-09-11 review of Eric's ingest proposal (Eric Searcy, Heather Willson, David Deal). Data correction — "member" in the Org Lens means "has ever held a membership", not "is a member today". member-service's catalogue predicate is `Product2.Family = 'Membership' AND IsDeleted = false` with no status filter (lfx-v2-member-service account_repo.go:41-45), so Expired and Invoice Cancelled assets qualify like Active ones. Across the B2B org 8,065 accounts have a Membership Asset but only 4,595 hold a current one. Of our 1,048 "visible today" companies, only 691 are current members; of the 808 visible active-CCLA orgs, only 529 are. This explains the non-members Heather observed in the Lens (existing intended behavior, not a defect) and means the predicate widening is a smaller semantic change than it appeared. Also: - §2.3 resolves Eric's "50% vs 75% gap" question: he matches against all B2B accounts, the Lens additionally filters on the Membership Asset. The difference is exactly gate 1b, and both proposals call for the same fix. - New §4.5 records Heather's two questions (must a company be B2B before signing a CCLA; what do B2C-only orgs see) and Eric's proposal to start CCLA signing from a dedicated CLA landing page rather than the Org Lens — which, if chosen, largely dissolves the signatory-selector problem in §4.2. - New open item 2 for that product decision; items renumbered to 6. - Headline sentence and appendix updated for the corrected gate semantics. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- .../easycla-ss-migration/m3-org-visibility.md | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 8b600313b..a5e3728dd 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -3,11 +3,11 @@ SPDX-License-Identifier: CC-BY-4.0 --> # M3 Org Lens: Why EasyCLA Companies Are Invisible, and What Makes Them Visible -**Status**: Updated after the 2026-09-10 architecture call · data re-measured 2026-09-10 +**Status**: Updated after the 2026-09-10 architecture call and the 2026-09-11 proposal review · data re-measured 2026-09-11 **Owner**: Michal (engineering) **Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [EasyCLA → LFX One recap](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) (Luis, rev 5, 2026-09-10) — the CLA-service plan, spec package `specs/044-lfx-v2-cla-service/` on branch `044-lfx-v2-cla-service` -**The problem in one line**: the Self Serve Org Lens lists LF **member** organizations, most EasyCLA customers are **not** members, so most CLA managers would open Self Serve and see nothing. +**The problem in one line**: the Self Serve Org Lens lists organizations that have **held an LF membership**, most EasyCLA customers never have, so most CLA managers would open Self Serve and see nothing. --- @@ -54,8 +54,10 @@ The 530 `lf`-prefixed values are the Org Service's own 18-character identifiers, |---|---:|---:| | Distinct real SFIDs in EasyCLA | 2,995 | 100% | | …with an account in the B2B org | 1,362 | 45% | -| ……member → **visible in Self Serve today** | **1,048** | **35%** | -| ……present but non-member (gate 1b) | 314 | 10% | +| ……has a Membership Asset → **visible in Self Serve today** | **1,048** | **35%** | +| ………of which hold a *current* membership | 691 | 23% | +| ………of which are lapsed — visible but not members today | 357 | 12% | +| ……present, no Membership Asset ever (gate 1b) | 314 | 10% | | …with **no B2B-org account** (gate 1a) | **1,633** | **55%** | | ……still a live account in the old platform org | 1,559 | | | ……dangling — resolves nowhere | 74 | | @@ -65,10 +67,12 @@ Restricted to companies with an **active signed CCLA** — the population that a | Metric | Count | Share | |---|---:|---:| | Orgs with an active signed CCLA (real SFID) | 1,948 | 100% | -| …visible today (member in B2B org) | 808 | 41% | -| …invisible — non-member 180, absent 960 | **1,140** | **59%** | +| …visible today (has a Membership Asset) | 808 | 41% | +| ……of which hold a *current* membership | 529 | 27% | +| ……of which are lapsed | 280 | 14% | +| …invisible — no Membership Asset 180, absent 960 | **1,140** | **59%** | -Membership uses the exact gate member-service applies: a B2B-org `Account` having an `Asset` whose `Product2.Family = 'Membership'` (8,064 of 18,231 accounts qualify). +> **"Member" here means *has ever held a membership*, not *is a member today*.** member-service's catalogue predicate is `Product2.Family = 'Membership' AND IsDeleted = false` with **no status filter** ([`account_repo.go:41-45`](https://github.com/linuxfoundation/lfx-v2-member-service/blob/main/internal/infrastructure/salesforce/account_repo.go#L41-L45)) — an `Expired` or `Invoice Cancelled` Asset qualifies exactly like an `Active` one. Across the whole B2B org, 8,065 accounts have a Membership Asset but only 4,595 hold a current one; the other 3,470 are lapsed and still in the Org Lens. This is why the Lens shows organizations that are not members — Heather observed this on 2026-09-11 and Eric traced the example to a 2020–2023 LFN membership. It is existing intended behavior, not a defect, but it matters here in two ways: it inflates what "visible today" means (of our 1,048, only 691 are current members), and it means **widening the predicate to "membership or CLA-referenced" is a smaller semantic change than it first appears** — the catalogue already contains non-members. > **Basis note.** All counts are distinct IDs, not company rows — 3,537 rows collapse to 3,531 values, the duplicate-row problem tracked in [lfx-self-serve#2056](https://github.com/linuxfoundation/lfx-self-serve/issues/2056). B2B-org matching compares the first 15 characters, since Salesforce 15- and 18-character IDs denote the same record. Totals drift by a few rows between syncs as DynamoDB grows; percentages are stable. @@ -90,6 +94,8 @@ The two "not covered" figures are close, but they are **not the same set**, and - **11** orgs are present by ID but have no domain match, so Eric's method counts them as needing an account they already have. A negligible error bound on his figure, noted for completeness. - Eric's population includes the `lf`-shaped and broken-reference companies that §2.1 separates out, which is why his totals run higher. +> **The "50% vs 75% gap" question, resolved.** Eric [raised this on 2026-09-10](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/README.md): his scripts report a ~50% gap while the architecture call discussed ~75%, and he correctly identified the cause — he matches against **all** B2B accounts, whereas the Org Lens additionally filters to accounts holding a Membership Asset. Both numbers are right at their own gate, and the difference *is* gate 1b. In this document's terms: 45% of EasyCLA's real SFIDs resolve to a B2B account (Eric's gate), but only 35% clear the membership filter as well (the Lens gate). His conclusion that the membership constraint must also be removed is the same change as the predicate widening in §4.2 — the two proposals agree, and neither is sufficient alone. + **Steady state after the backfill**: ~362 new CCLA companies/year, of which ~196 (~16/month) need a new account — flat over three years. > **Caveat worth raising with Eric.** The 1,725 "already maps" and the 374 links above are *inferred from domain equality*, not verified identity. Shared or reused domains (subsidiaries, acquisitions, ISP-hosted sites) can mislink. Eric's staged review covers the records he *creates*; the links get no equivalent review pass, yet a mislink silently points a signed CCLA at the wrong company. Worth a review gate on the link set, not just the create set. @@ -112,7 +118,7 @@ flowchart LR 1. **The org record does not exist**, in two layers. **(1a)** 55% of EasyCLA orgs with a real SFID have no account in the B2B Salesforce org — they were left behind in the old platform org during the B2C decouple. No predicate change can surface them; the accounts must be ingested. **(1b)** The 314 that do exist fail the Membership-Asset gate — predicate widening covers exactly these, and nothing else. 2. **The user holds no grant.** Org Lens eligibility is an OpenFGA relation on `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS and Org Service scopes; OpenFGA knows nothing about them. -> **ID remapping is a required work item.** Salesforce cannot create a record with a chosen ID, so every newly created account gets a **new SFID**. Combined with the 374 domain-links and the 530 `lf`-shaped IDs, EasyCLA's stored `company_external_id` will not resolve for most of the affected population. The ingest must produce an old-ID → new-ID map that EasyCLA (or the CLA service's mapping store) applies. Only the 1,362 already present in the B2B org carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 4](#5-open-items). +> **ID remapping is a required work item.** Salesforce cannot create a record with a chosen ID, so every newly created account gets a **new SFID**. Combined with the 374 domain-links and the 530 `lf`-shaped IDs, EasyCLA's stored `company_external_id` will not resolve for most of the affected population. The ingest must produce an old-ID → new-ID map that EasyCLA (or the CLA service's mapping store) applies. Only the 1,362 already present in the B2B org carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 5](#5-open-items). --- @@ -160,7 +166,7 @@ flowchart LR - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. -> **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. +> **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. **See also §4.5** — if CCLA signing starts from a dedicated CLA landing page rather than the Org Lens, as Eric has proposed, a signatory never needs selector access and this question narrows to signatories who must *review* an existing agreement. ### 4.3 What changed versus the pre-call version @@ -182,17 +188,29 @@ Admitting ~1,600 non-member companies to the org catalogue changes more than the | **Volume** | The org list grows by roughly 9.4%. Affects the staff selector (may need search-first), full-rebuild time, and the Salesforce API budget. | Measure rebuild time before launch; adjust the selector if needed. | | **Making onboarding stick** | `b2b_org_ensure` is a one-time event. A full rebuild starts from Salesforce again and **drops these companies** unless "referenced by a CLA" is stored durably. | Choose the durable signal — a field on the Salesforce account set by EasyCLA, or a marker LFX keeps — as part of the decision. | -The last one compounds the remap problem in [open item 4](#5-open-items): both are silent failures surfacing only as an org that quietly stops appearing. +The last one compounds the remap problem in [open item 5](#5-open-items): both are silent failures surfacing only as an org that quietly stops appearing. + +### 4.5 The entry-point question this raises (Heather / Eric, 2026-09-11) + +Reviewing the ingest proposal, Heather Willson asked two questions that this document's data does not answer and that bound its scope. Recorded here because they gate what M3 should build, not because they are settled: + +1. **Must a company be a B2B account before it can sign a CCLA?** Under this proposal, yes — CCLA signing creates the account. Eric's position is that this is true regardless of where B2B orgs are stored, and is already the case in v1: if the Org Service organization does not exist, the signing flow has to create one. The change is *what gets created* (a Salesforce account instead of an Org Service record), not *whether* something is. +2. **What do non-B2B (B2C-only) organizations see in the Org Lens?** Today, Insights routes such users to a dead end. The options split on what the user is meant to *do* there: a read-only view can be built from CDP attestation data alone and needs no account; anything transactional — signing a CCLA, joining as a member — routes through account creation regardless. Eric's framing: there is no meaningful "view-only org" action for a company with no memberships, no committee seats, and no project logo. + +Eric also questions whether CCLA signing should start from the Org Lens at all — the alternative being a single CLA landing page that disambiguates ICLA/ECLA/CCLA, carries the user through org creation, and hands off to the Org Lens only once there is an org to land in (the pattern already used for Member Enrollment, which does not live in the Org Dashboard). **If that is the chosen flow, the signatory-selector problem in §4.2 largely dissolves** — a signatory would never need to enter the Lens to sign. That makes this a product decision with direct architectural consequence, not a sequencing detail. + +A related unresolved question: how a user's Org Lens is determined for attested (non-CCLA) organizations — by email domain, by work-history employer, and what happens with generic email domains or multiple concurrent employers. Eric considers these foundational to the engagement model rather than edge cases. Out of scope for M3 as scoped here, which keys on CCLA signatures rather than attestation, but it constrains any later "every employee sees their employer" ambition. --- ## 5. Open items 1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16); Eric is opening the conversation. Heather Willson is out the week of 2026-09-14, so her involvement follows. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. -2. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. -3. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. -4. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. -5. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. +2. **Product decision on the CCLA entry point** (§4.5) — does CCLA signing start from the Org Lens, or from a dedicated CLA landing page that creates the org and hands off? Raised by Heather Willson and Eric Searcy on 2026-09-11; David Deal's position is that product should drive these requirements. This is upstream of the selector-union question in §4.2 and can eliminate it. Unowned as of this writing. +3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. +4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. +5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. +6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- @@ -244,4 +262,6 @@ All figures in §2 come from Snowflake, measured 2026-09-10, read-only: Rules applied throughout: exclude `IS_DELETED`/`ISDELETED` and `_FIVETRAN_DELETED`; join B2B IDs on `LEFT(id, 15)`; treat `company_external_id` as a real SFID only when it matches `001%`. Active CCLA means a `CLA_PROD_SIGNATURES` row with `signature_type = 'ccla'`, `signature_reference_type = 'company'`, and both `signature_signed` and `signature_approved` true. +The membership gate deliberately applies **no** `ASSET.STATUS` filter, matching member-service's SOQL. The current-vs-lapsed split in §2.2 adds `STATUS IN ('Active','Purchased','At Risk')`; the remaining statuses in the data are `Expired`, `Invoice Cancelled` and `Associate Cancelled`. Re-measured 2026-09-11: the §2.2 and §2.3 totals reproduce exactly, with active-CCLA counts drifting by one row (1,949/809) as DynamoDB grows — the drift the basis note describes. + Eric's domain-matching figures are reproducible from [his scripts](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend/scripts); the 374/11 overlap figures in §2.3 come from running his classification and the SFID-presence test over the same rows in one query. From f4f34d032538b6c036f1d126f9e2feae4bc9b90a Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 11 Sep 2026 16:14:24 -0700 Subject: [PATCH 11/23] docs: fix stale cross-references and the membership-gate count Verified every figure in m3-org-visibility.md against prod Snowflake and corrected three internal inconsistencies: - section 3 mermaid: membership gate said 8,064 accounts, contradicting section 2.2's re-measured 8,065 - section 6: "reopening that approval at the spec-044 ADR review (open item 2)" pointed at the entry-point question after the renumbering; the ADR review is open item 3 - section 6: "see open item 5 for the parity requirement" pointed at the SFID remap; Bridge/FGA parity is open item 6 Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index a5e3728dd..89797f807 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -107,7 +107,7 @@ The two "not covered" figures are close, but they are **not the same set**, and ```mermaid flowchart LR OLD[("Old platform Salesforce
100,592 accounts — where
EasyCLA IDs point")] -.->|"B2C decouple carved out
18.2k accounts, IDs preserved"| SF - SF["B2B Salesforce org
18,231 accounts"] -->|"gate 1b: has Membership Asset
(8,064 accounts)"| MS["member-service"] + SF["B2B Salesforce org
18,231 accounts"] -->|"gate 1b: has Membership Asset
(8,065 accounts)"| MS["member-service"] MS -->|"b2b_org docs"| QS["query-service
(OpenSearch)"] MS -->|"writer / auditor tuples"| FGA["OpenFGA"] QS -->|"org list"| SS["Self Serve
Org Lens"] @@ -227,14 +227,14 @@ The 2026-09-10 call reversed the position that CLA object types enter the platfo | [`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) | line 223 — "deferred to M5 scope… no CLA object types in the platform authorization model" | | Epic [lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) | same statement | -**P2 is not a passing mention — it is an architecture-review-approved proposal** (Eric, with the endpoint-deprecation risk closed 2026-07-31, ARCH-406). Reversing it means reopening that approval at the spec-044 ADR review (open item 2), not merely editing prose. Until these are updated, two incompatible authorization architectures are documented side by side. +**P2 is not a passing mention — it is an architecture-review-approved proposal** (Eric, with the endpoint-deprecation risk closed 2026-07-31, ARCH-406). Reversing it means reopening that approval at the spec-044 ADR review (open item 3), not merely editing prose. Until these are updated, two incompatible authorization architectures are documented side by side. Two further documents describe behavior that changes but are not "superseded" in the same sense: - [`docs/M3_ORG_LENS_API.md`](../M3_ORG_LENS_API.md) documents per-endpoint **ACS scope** authorization for shipped endpoints. If CLA FGA types land in M3, this describes live behavior that changes — arguably a higher-stakes update than the planning specs. - `spec.md` FR-032 pins role-assignment consistency to "the system of record used by EasyCLA's enforcement" (ACS), which an M3 FGA move puts in tension. -**What does not change**: FGA governs **lens entry and UI gating**; EasyCLA v4 via ACS remains the **enforcement** point for every write through M3. Two layers, not two systems of record — but see open item 5 for the parity requirement that makes this safe. +**What does not change**: FGA governs **lens entry and UI gating**; EasyCLA v4 via ACS remains the **enforcement** point for every write through M3. Two layers, not two systems of record — but see open item 6 for the parity requirement that makes this safe. --- From a6dcfd8ffff30faf9f5fd68eec37de3dff25c34b Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Wed, 16 Sep 2026 23:32:39 -0700 Subject: [PATCH 12/23] docs: propose minimal M3 FGA model (one org-level cla_admin relation) Adds m3-fga-model.md: the M3 slice of the FGA model needs exactly one new relation, b2b_org#cla_admin, gating the UI only (org selector entry and the EasyCLA tab). ACS keeps authorizing every EasyCLA v4 call over the bridge, so spec 044's four CLA object types are deferred to the milestones where they actually enforce (cla_group at M4, per-agreement relations at M5). Also records how designee and signatory access carries over to the Org Lens: designees need no tuple, since the initiating designee becomes the signature_acl's sole initial entry at DocuSign request time, and signatories have no console access today that a cutover could remove. Cross-references the proposal from the folder README and from m3-org-visibility.md 4.2, so both model shapes are visible to the spec-044 ADR review. Signed-off-by: Michal Lehotsky Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/README.md | 3 +- docs/easycla-ss-migration/m3-fga-model.md | 124 ++++++++++++++++++ .../easycla-ss-migration/m3-org-visibility.md | 2 +- 3 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 docs/easycla-ss-migration/m3-fga-model.md diff --git a/docs/easycla-ss-migration/README.md b/docs/easycla-ss-migration/README.md index b83c336d2..e0303aa40 100644 --- a/docs/easycla-ss-migration/README.md +++ b/docs/easycla-ss-migration/README.md @@ -8,6 +8,7 @@ Self-contained materials for the architecture review of the EasyCLA-to-Self-Serv 1. **[architecture-proposal.md](architecture-proposal.md)** — the reviewed proposal (Eric Searcy, 2026-07-20). Current state, milestones, what leadership already settled, the proposed architecture (P1–P10, including the audit and trusted-caller designs), top risks, and what the review should challenge. 2. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`. 3. **[m3-org-visibility.md](m3-org-visibility.md)** — why most EasyCLA companies are invisible in the Org Lens and what makes them visible (2026-09-10 call outcome, with prod data). **Reverses P2's "no CLA object types in OpenFGA before M5"** — read it against item 1. -4. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session. +4. **[m3-fga-model.md](m3-fga-model.md)** — the proposed M3 slice of that model: one org-level `cla_admin` relation, gating the UI only, with ACS still authorizing every API call. Simplifies item 3's §4.2 — read it against that section. +5. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session. Implementation-level specifications (milestone scopes, acceptance criteria, per-milestone plans — used by the Spec Kit workflow) live separately in [specs/001-easycla-ss-integration-fable/](../../specs/001-easycla-ss-integration-fable/spec.md). This folder is for evaluating the architecture; that folder is for building it. diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md new file mode 100644 index 000000000..165a127f4 --- /dev/null +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -0,0 +1,124 @@ + + +# M3 FGA model — one org-level relation, UI-only + +**Status**: proposal, for the spec-044 ADR review (simplifies the M3 slice of the model agreed on the 2026-09-10 architecture call) +**Owner**: Michal (engineering) +**Related**: [m3-org-visibility.md](m3-org-visibility.md) §4.2 (the four-type variant this simplifies) · [role-mapping-feasibility.md](role-mapping-feasibility.md) + +**Spec 044** = the [lfx-v2-cla-service plan](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) +(Luis, rev 5, 2026-09-10): a new platform CLA service that mirrors EasyCLA's DynamoDB into +OpenSearch/OpenFGA and proxies writes back to v4. Its spec package +`specs/044-lfx-v2-cla-service/` (branch `044-lfx-v2-cla-service`) is not yet published in +any `linuxfoundation` repo; the recap doc above is the citable source. + +**In one line**: M3 needs exactly one thing from OpenFGA — *CLA managers can enter their org in the Org Lens and see the EasyCLA tab* — so M3 adds exactly one relation, and everything below the tab stays with the existing v4 APIs under ACS. + +**Division of labor — FGA gates the UI, ACS gates the APIs.** OpenFGA decides only what +the Self Serve UI shows (org selector entry, EasyCLA tab). Every EasyCLA API call is +authorized by ACS, unchanged: gateway → ACS warden → `X-ACL` header → v4 scope check. +FGA never authorizes an API call; ACS never decides what the UI renders. This holds for +the whole M3→M5 bridge period. + +--- + +## 1. The one requirement + +Most CLA managers hold no membership-based grant, so without a new FGA grant they open +Self Serve and have no organization to select (gate 2 in +[m3-org-visibility.md](m3-org-visibility.md) §1). The tab's *contents* don't need FGA in +M3: the CLA pages run on the bridge (Self Serve → EasyCLA v4 via the API gateway), where +every request is already enforced by ACS. + +## 2. The model + +One new relation on the existing org type. No new object types in M3. + +``` +type b2b_org + relations + ...existing (writer, auditor, ...) + define cla_admin: [user] # working name; "cla_manager" also fits +``` + +| Question | Answered by | How | +|---|---|---| +| Does org X appear in the user's selector? | FGA | user holds any org relation, `cla_admin` now included | +| Does the EasyCLA tab render for org X? | FGA | `cla_admin` (or an org read relation) | +| Which CCLAs are listed, which actions work? | ACS via the v4 bridge | unchanged — gateway warden check + `X-ACL` scopes, per request | + +`cla_admin` grants **nothing else**: no org read, no People/membership access. A user +holding only `cla_admin` gets the CLA-only view — the org appears in their selector and +the EasyCLA tab renders; every other section (People, memberships, key contacts, +meetings) is hidden or empty, because query-service fails closed without a `b2b_org` +read relation and member-service routes require `auditor`/`writer`. On the API side they +still reach CLA data only: v4 authorizes them via their existing ACS `cla-manager` role +over the bridge; non-CLA org APIs deny them. Org admin ≠ CLA manager stays true in both +directions ("never grant org-wide read to CLA managers", spec 044 Q4). + +## 3. Tuple lifecycle + +- **Source**: the CCLA signature's `signature_acl` (DynamoDB) — the synchronous write, + same source for backfill and ongoing projection. Never derived from ACS. +- **Projection**: managers of any of an org's CCLAs → one `b2b_org:#cla_admin` tuple + per user × org (deduped across the org's CLA groups). Remove the tuple when the user + leaves the last `signature_acl` of that org. +- **Cross-check**: the read-only ACS drift report (spec 044 item 22) compares ACS + `cla-manager` roles against the tuples; run it periodically, not once — ACS keeps being + written by v4 for as long as the bridge exists. + +## 4. What this defers, and what it costs + +Spec 044's four CLA types (`cla_group`, `cla_ccla` with `manager`/`signatory`, +`cla_ecla`, `cla_icla`) earn their keep only when FGA starts **enforcing** — per-agreement +read authorization on the query plane, at M5. Nothing in M3 uses them: + +| Spec-044 type | First actually needed | +|---|---| +| `cla_ccla` per-agreement relations | M5 — query-plane enforcement (`#auditor`) | +| `cla_group` | M4 — project lens | +| `cla_ecla`, `cla_icla` | M5 — Me lens works today on in-handler ownership checks, no tuples | + +**Cost**: a second FGA model bump at M5, when the per-agreement types replace +`cla_admin`. Acceptable because the M5 bump happens under spec 044 regardless, and the +tuples are projections — migrating is a re-backfill from `signature_acl`, not a data +migration. + +**Side benefit**: the relation sits on whatever org object exists, so the M3 model no +longer depends on the open "EasyCLA orgs become B2B Salesforce accounts" decision +([linuxfoundation/easycla#5210](https://github.com/linuxfoundation/easycla/pull/5210)). + +## 5. Explicitly out of scope for this model + +Unchanged open items — this proposal solves none of them, under either model shape: + +1. **CLA-only view** — UI work to hide membership sections for `cla_admin`-only users. +2. **Designees** — `cla-manager-designee` exists only in ACS, with no `signature_acl` + entry before the CCLA is signed, so no tuple can be projected — and none is needed: + a designee's only job is the pre-signing window (initiate DocuSign), which runs on the + unscoped Sign CLA flow, not inside an org's lens. The moment they have something to + see in the lens (a signed CCLA) is the moment `signature_acl` — and therefore their + `cla_admin` tuple — exists, because v4 writes the initiating designee as the ACL's + sole initial entry ([sign/service.go:2949](../../cla-backend-go/v2/sign/service.go#L2949)). +3. **Signatory lens access** — no M3 work: signatories have no console access today + (email-only DocuSign interaction; the `cla-signatory` ACS role is checked by no + endpoint). Proper read access is spec 044's `cla_ccla#signatory` at M5. Do **not** + fold signatories into `cla_admin` — the tab would offer manager actions v4 rejects. +4. **ACS/FGA parity** — FGA gates the UI, ACS enforces the API, for the whole M3→M5 + bridge period; both are fed by the same v4 write (`signature_acl` synchronous, ACS + role asynchronous), and disagreement handling remains open item 6 there. + +## 6. Do not add per-manager filtering to v4 + +"The API decides the contents" means the **existing** enforcement: an org-scoped user +sees the org's CLA list; writes are gated per project|org scope. Building new per-user +filtering into v4 would diverge from today's Corporate Console behavior and violate the +program rule that Self Serve mirrors v4's decisions rather than re-deriving them +([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). + +## 7. Decision venue + +The spec-044 ADR review (open item 3 in +[m3-org-visibility.md](m3-org-visibility.md) §5): adopt this as the M3 model, with the +four CLA types moved to the M5 ADR where they become enforcing. diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 89797f807..d679a11c9 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -162,7 +162,7 @@ flowchart LR ``` - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. -- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. From 370afe25f5c590c9574a8368f653d662ebb15747 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 00:03:29 -0700 Subject: [PATCH 13/23] docs(review): address PR #5210 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: - m3-org-visibility.md: caveat the spec-044 header link — the spec package/branch it names is not published anywhere, so the Google Doc recap is the only citable source for every "spec 044" reference in the file (per copilot-pull-request-reviewer) - m3-org-visibility.md: fix the active-CCLA lapsed count (280 -> 279) so the subtotal reconciles with the stated 808 total (per copilot-pull-request-reviewer) - m3-org-visibility.md: document a fourth remap blast-radius in open item 5 — existing ACS CLA-manager scopes are keyed to the SFID at grant time and go stale after an SFID remap, causing v4 to 403 even though FGA still admits the user to the lens (per copilot-pull-request-reviewer) - m3-fga-model.md: reconcile the cla_group milestone — the table said M4, but the cost analysis and §7 decision venue both put all four spec-044 types at M5; corrected the table to M5 (per copilot-pull-request-reviewer) Resolves 4 review threads. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 2 +- docs/easycla-ss-migration/m3-org-visibility.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index 165a127f4..1ad469183 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -77,7 +77,7 @@ read authorization on the query plane, at M5. Nothing in M3 uses them: | Spec-044 type | First actually needed | |---|---| | `cla_ccla` per-agreement relations | M5 — query-plane enforcement (`#auditor`) | -| `cla_group` | M4 — project lens | +| `cla_group` | M5 — project lens does not read CLA data through FGA before then | | `cla_ecla`, `cla_icla` | M5 — Me lens works today on in-handler ownership checks, no tuples | **Cost**: a second FGA model bump at M5, when the per-agreement types replace diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index d679a11c9..38e64f175 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -5,7 +5,7 @@ SPDX-License-Identifier: CC-BY-4.0 --> **Status**: Updated after the 2026-09-10 architecture call and the 2026-09-11 proposal review · data re-measured 2026-09-11 **Owner**: Michal (engineering) -**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [EasyCLA → LFX One recap](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) (Luis, rev 5, 2026-09-10) — the CLA-service plan, spec package `specs/044-lfx-v2-cla-service/` on branch `044-lfx-v2-cla-service` +**Related**: [architecture-proposal.md](architecture-proposal.md) P2 · [role-mapping-feasibility.md](role-mapping-feasibility.md) §6 · [EasyCLA → LFX One recap](https://docs.google.com/document/d/1hyWZUE_kofeAjVSmeXsRXtPSxSsj7uWvRj3tTNgwdik/edit) (Luis, rev 5, 2026-09-10) — the CLA-service plan. Its spec package `specs/044-lfx-v2-cla-service/` (branch `044-lfx-v2-cla-service`) is not yet published in any `linuxfoundation` repo; the recap doc above is the citable source for every "spec 044" reference in this file. **The problem in one line**: the Self Serve Org Lens lists organizations that have **held an LF membership**, most EasyCLA customers never have, so most CLA managers would open Self Serve and see nothing. @@ -69,7 +69,7 @@ Restricted to companies with an **active signed CCLA** — the population that a | Orgs with an active signed CCLA (real SFID) | 1,948 | 100% | | …visible today (has a Membership Asset) | 808 | 41% | | ……of which hold a *current* membership | 529 | 27% | -| ……of which are lapsed | 280 | 14% | +| ……of which are lapsed | 279 | 14% | | …invisible — no Membership Asset 180, absent 960 | **1,140** | **59%** | > **"Member" here means *has ever held a membership*, not *is a member today*.** member-service's catalogue predicate is `Product2.Family = 'Membership' AND IsDeleted = false` with **no status filter** ([`account_repo.go:41-45`](https://github.com/linuxfoundation/lfx-v2-member-service/blob/main/internal/infrastructure/salesforce/account_repo.go#L41-L45)) — an `Expired` or `Invoice Cancelled` Asset qualifies exactly like an `Active` one. Across the whole B2B org, 8,065 accounts have a Membership Asset but only 4,595 hold a current one; the other 3,470 are lapsed and still in the Org Lens. This is why the Lens shows organizations that are not members — Heather observed this on 2026-09-11 and Eric traced the example to a 2020–2023 LFN membership. It is existing intended behavior, not a defect, but it matters here in two ways: it inflates what "visible today" means (of our 1,048, only 691 are current members), and it means **widening the predicate to "membership or CLA-referenced" is a smaller semantic change than it first appears** — the catalogue already contains non-members. @@ -210,6 +210,7 @@ A related unresolved question: how a user's Org Lens is determined for attested 3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. 4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. + **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:143`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L143), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L202)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- From 285859b0bf35e03cf9ef4e087d2cfdd7a43a1a4e Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 13:16:33 -0700 Subject: [PATCH 14/23] docs: record sales-ops meeting outcome for the B2B ingest Salesforce team has no objection in principle; confirming internally with Dolan/Stephanie, answer expected week of 2026-09-21. They expect an Apex-exposed interface rather than direct sObject create access, will accept an AI-written contribution to accelerate it, and want it closed out before renewal season. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 38e64f175..a1b9a85a9 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -133,7 +133,7 @@ Eric's written proposal — [Consolidate B2B backend, ingest EasyCLA companies a 1. **CCLA signing is a recognized B2B onboarding path** — structurally parallel to member enrollment, minus the financial relationship. Consistent with Eric's earlier [organization-decoupling proposal](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2024-12%20Decoupling%20orgs%20and%20users/README.md#a-proposal-for-organization-decoupling) (2024-12), where B2B org records are created only when a user starts a B2B flow "like Member Enrollment or Corporate CLA", and the parallel LFX org database is sunset rather than extended. 2. **LFX creates the ~1,709 accounts**, staged and reviewable in tranches; Sales Ops approves the records and field semantics. `IsMember__c` stays **false** — which is precisely why the predicate widening in §4.2 is still required: ingested accounts would otherwise keep failing the Membership-Asset gate. 3. **The 258 companies with a missing or broken org reference are excluded** from the ingest and remediated in EasyCLA (§6 prerequisites). Since EasyCLA stores no company domain, the one credible signal is the CLA managers' email domains — a per-record exercise, not a query. -4. **Future CCLA onboarding routes through Salesforce account creation at signing time**, so the backfill is one-time. End-user UX does not change. *Which* service creates the account — a membership-free Apex endpoint that keeps matching policy in Salesforce, or LFX calling the standard Account API with its own domain dedupe — is an open governance question in Eric's proposal and does not need resolving here. +4. **Future CCLA onboarding routes through Salesforce account creation at signing time**, so the backfill is one-time. End-user UX does not change. *Which* service creates the account was an open governance question in Eric's proposal; per the 2026-09-17 sales-ops meeting (open item 1), the expected direction is a membership-free Apex endpoint that keeps matching policy in Salesforce, not direct sObject create access from LFX. > **Mechanical note.** member-service's existing `create-b2b-org` (`POST /b2b_orgs`) **registers** an Account that already exists in Salesforce; it does not create one, and it performs no duplicate check ([TECHNICAL.md](https://github.com/linuxfoundation/lfx-architecture-scratch/blob/main/2026-09-Consolidate-B2B-Backend/TECHNICAL.md) §5). That is the same operation as `b2b_org_ensure` below — account creation happens upstream of it. @@ -205,7 +205,7 @@ A related unresolved question: how a user's Org Lens is determined for attested ## 5. Open items -1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16); Eric is opening the conversation. Heather Willson is out the week of 2026-09-14, so her involvement follows. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. +1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy White (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16). **Update 2026-09-17**: met with the Salesforce team; no objection in principle, but they want to confirm internally with Dolan/Stephanie before committing effort this close to renewal season — answer expected the week of 2026-09-21. Three takeaways: (a) they expect LFX to go through an Apex-exposed interface, not direct sObject create access (see §4.1 item 4); (b) they will accept an AI-written contribution to their repo, tested in sandbox, to accelerate the work; (c) they are motivated to close this out before their renewal-season load increases. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. 2. **Product decision on the CCLA entry point** (§4.5) — does CCLA signing start from the Org Lens, or from a dedicated CLA landing page that creates the org and hands off? Raised by Heather Willson and Eric Searcy on 2026-09-11; David Deal's position is that product should drive these requirements. This is upstream of the selector-union question in §4.2 and can eliminate it. Unowned as of this writing. 3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. 4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. From c569b03143368fe84c80e99f5f3871c259168da6 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 14:08:30 -0700 Subject: [PATCH 15/23] docs: record v4's two-tier CLA enforcement and the M3 parity decision Eric raised cross-project CCLA visibility as a possible legal concern. Tracing v4 shows the concern is read-tier only, and that M3 needs no new work: - Reads are company-wide: the shared lfx-kit scope matcher accepts a project|organization scope on its organization half alone, while CLA-manager ACS roles are granted as project|org pairs. - Writes are per-agreement and cannot be widened by staff: project|org tree scope with admin disallowed, plus a signature_acl membership check. Expand m3-fga-model.md section 6 from the one-line "no per-manager filtering" rule into the full two-tier account with file:line evidence, record that M3 replicates both tiers (the shipped M3 endpoints already encode them, so parity costs nothing), and note that narrowing reads would mean changing shared platform code rather than EasyCLA. Cross-project visibility deferred to M5, where cla_ccla#auditor gates reads per agreement anyway. Also explain why cla_admin is deliberately coarser than the write gate, and correct two stale cla_manager.go line references (143/202 -> 144/205). Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 64 +++++++++++++++++-- .../easycla-ss-migration/m3-org-visibility.md | 4 +- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index 1ad469183..f1f642223 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -109,13 +109,65 @@ Unchanged open items — this proposal solves none of them, under either model s bridge period; both are fed by the same v4 write (`signature_acl` synchronous, ACS role asynchronous), and disagreement handling remains open item 6 there. -## 6. Do not add per-manager filtering to v4 +## 6. What v4 already enforces — two tiers, and M3 replicates both -"The API decides the contents" means the **existing** enforcement: an org-scoped user -sees the org's CLA list; writes are gated per project|org scope. Building new per-user -filtering into v4 would diverge from today's Corporate Console behavior and violate the -program rule that Self Serve mirrors v4's decisions rather than re-deriving them -([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). +"The API decides the contents" means the **existing** enforcement, which is not uniform. +Reads and writes sit at different widths: + +| Tier | Gate | Effective scope | +|---|---|---| +| **Read / list** | `IsUserAuthorizedForOrganization(..., ALLOW_ADMIN_SCOPE)` | **Company-wide** | +| **Write / manage** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)`, then `CurrentUserInACL` on the signature | **Per CLA group**, twice over | + +The read tier is company-wide because the shared scope matcher accepts a +`project|organization` scope on its **organization half alone**, ignoring the project +half ([lfx-kit `auth/user.go` `IsUserAuthorizedForOrganizationScope`](https://github.com/LF-Engineering/lfx-kit/blob/main/auth/user.go)), +while CLA-manager ACS roles are granted as `project|org` pairs +([`v2/dynamo_events/cla_manager.go:205`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)). +So a manager appointed for one CLA group at a company passes the org-scope check on that +company's other CLA groups: `GetCompanyClaGroups` +([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134)) and the +other listing endpoints return the company's full CLA set. + +The write tier is narrow and cannot be widened by staff: the approval-list update checks +the project|org tree with **admin scope disallowed** +([`v2/signatures/handlers.go:121`](../../cla-backend-go/v2/signatures/handlers.go#L121)), +and the service layer then requires the caller to be in that specific signature's ACL +([`signatures/service.go:523`](../../cla-backend-go/signatures/service.go#L523), +[`v2/signatures/handlers.go:1499`](../../cla-backend-go/v2/signatures/handlers.go#L1499)). +Managing one CLA group's approval list therefore requires membership in *that* signature's +`signature_acl`; a manager on a sibling CLA group is refused. + +**M3 replicates both tiers unchanged** — this is feature parity, and it is also the +cheapest option. The shipped M3 endpoints already encode exactly these rules: the org CLA +list documents auth as "`organization` scope for the `companySFID`, or any +`project|organization` scope whose organization half matches", and the manager/ +acknowledgment write ops as "`project|organization` tree scope for the project/company +pair, LF admin disallowed" ([`docs/M3_ORG_LENS_API.md`](../M3_ORG_LENS_API.md)). Parity is +the result of *not* writing new code. + +**Do not add per-manager filtering to v4.** Building new per-user filtering would diverge +from today's Corporate Console behavior and violate the program rule that Self Serve +mirrors v4's decisions rather than re-deriving them +([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). Narrowing the read +tier is not an EasyCLA change at all — the breadth lives in the shared lfx-kit matcher +used by every LFX service, so it would mean either changing shared platform code or +bolting CLA-specific filtering onto v4. + +**Cross-project visibility was raised as a possible legal concern** (Eric, review of this +proposal): a CLA manager for one project can see that their employer holds agreements with +other projects. Scoped correctly it is a **read-tier** question only — no cross-group +writes are possible — and it describes current production behavior, not something the +migration introduces. Deferred to M5 rather than treated as M3 work: `cla_ccla#auditor` +gates reads per agreement once FGA enforces, so the narrowing comes with that milestone +instead of as bespoke M3 divergence. + +**Why `cla_admin` is deliberately coarser than the write gate.** The relation is projected +from `signature_acl` deduped across the org's CLA groups, so one ACL membership grants +lens entry to the org. That matches the read tier exactly, and it keeps the projection to +one tuple per user × org. A per-CLA-group relation would have to track v4's write gate, +giving two systems that can disagree — the parity problem in open item 6 of +[m3-org-visibility.md](m3-org-visibility.md) §5. ## 7. Decision venue diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index a1b9a85a9..1ab4fa8d8 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -162,7 +162,7 @@ flowchart LR ``` - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. -- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes — see [m3-fga-model.md](m3-fga-model.md) §6. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. @@ -210,7 +210,7 @@ A related unresolved question: how a user's Org Lens is determined for attested 3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. 4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. - **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:143`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L143), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L202)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. + **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- From eac728d3829009af99bc219bcf66030de28f8870 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 18 Sep 2026 09:17:46 -0700 Subject: [PATCH 16/23] docs: record the product decision on cross-project CLA visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heather Willson settled the question Eric raised in review: of full read-only, listed-but-not-openable, and hidden entirely, full read-only is correct — a CLA's information should not be hidden, because someone may need to become a CLA manager or get authorized under their company's CCLA, and they can do neither if they cannot see the agreement exists. Consequences for the doc: - Company-wide CLA visibility is intended product behavior, not a gap. No M3 work and no Legal escalation; v4 already does this. - It constrains M5 rather than being deferred to it. The prior revision assumed cla_ccla#auditor would narrow reads per agreement once FGA enforces; that is now wrong, and the M5 model must preserve the company-wide read instead. - Record per-CLA-group API filtering as a rejected alternative, with the three reasons it fails independently of the product call: it breaks needsClaManager and new-manager onboarding, it puts FGA and the API at different widths, and it would make reads narrower than the project|org tree write gate. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 47 ++++++++++++++----- .../easycla-ss-migration/m3-org-visibility.md | 2 +- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index f1f642223..9ae3a8900 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -149,18 +149,41 @@ the result of *not* writing new code. **Do not add per-manager filtering to v4.** Building new per-user filtering would diverge from today's Corporate Console behavior and violate the program rule that Self Serve mirrors v4's decisions rather than re-deriving them -([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). Narrowing the read -tier is not an EasyCLA change at all — the breadth lives in the shared lfx-kit matcher -used by every LFX service, so it would mean either changing shared platform code or -bolting CLA-specific filtering onto v4. - -**Cross-project visibility was raised as a possible legal concern** (Eric, review of this -proposal): a CLA manager for one project can see that their employer holds agreements with -other projects. Scoped correctly it is a **read-tier** question only — no cross-group -writes are possible — and it describes current production behavior, not something the -migration introduces. Deferred to M5 rather than treated as M3 work: `cla_ccla#auditor` -gates reads per agreement once FGA enforces, so the narrowing comes with that milestone -instead of as bespoke M3 divergence. +([role-mapping-feasibility.md](role-mapping-feasibility.md) §3/§5). Nor is narrowing the +read tier an EasyCLA change at all — the breadth lives in the shared lfx-kit matcher used +by every LFX service, so it would mean changing shared platform code or bolting +CLA-specific filtering onto v4. The concrete version of this proposal, and why it fails on +its own terms, is recorded as a rejected alternative below. + +**Cross-project visibility: settled as intended product behavior, not a gap.** Eric raised +it in review of this proposal as a possible legal concern — a CLA manager for one project +can see that their employer holds agreements with other projects. Scoped correctly it is a +**read-tier** question only; no cross-group writes are possible. Put to Product +(Heather Willson, 2026-09-18) as a choice between full read-only, listed-but-not-openable, +and hidden entirely: **full read-only is the decision**, on the grounds that a CLA's +information should not be hidden, precisely because someone may need to become a CLA +manager or get authorized under their company's CCLA — and they can do neither if they +cannot see the agreement exists and who manages it. No Legal escalation, and no M3 work: +this is what v4 already does. + +**This constrains M5, it is not deferred to it.** An earlier revision of this section +parked the question for M5 on the assumption that `cla_ccla#auditor` would narrow reads per +agreement once FGA enforces. That reading is now wrong: since company-wide CLA visibility +is deliberate, M5's per-agreement read model must **preserve** it rather than narrow it — +`#auditor` has to keep admitting a company's non-manager CLA admins to read its other CLA +groups. Carry this into the M5 ADR as a requirement on the model, not an open item. + +**Rejected alternative: filter the list per CLA group in v4.** Considered — the response +already carries `claManagers` per row from `signature_acl` +([`v2/company/service.go:1522`](../../cla-backend-go/v2/company/service.go#L1522)), so +filtering to "CLA groups where the caller is a manager" would cost ~10 lines and no extra +queries. Rejected on three counts, before the Product decision made it moot: it breaks +`needsClaManager` (an agreement with **zero** managers can match no caller, so the rows the +field exists to surface would vanish) and newly appointed managers (empty lens, no route +forward); it puts FGA and the API at different widths, since `cla_admin` is org-level by +construction; and it would make reads *narrower than writes* in the foundation case, where +the write gate is a project|org **tree** check — a manager appointed at foundation level can +write on a child project's agreement whose own ACL they are not in. **Why `cla_admin` is deliberately coarser than the write gate.** The relation is projected from `signature_acl` deduped across the org's CLA groups, so one ACL membership grants diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 1ab4fa8d8..7e75b46a1 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -162,7 +162,7 @@ flowchart LR ``` - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. -- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes — see [m3-fga-model.md](m3-fga-model.md) §6. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. From f2e17fbac583b2e825a97bd262b37b29bc2fd8ce Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 18 Sep 2026 22:35:01 -0700 Subject: [PATCH 17/23] docs: record sfid_b2b crosswalk findings and the B2B import confirmation path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snowflake measurements (2026-09-18, read-only): the old org's sfid_b2b covers 100% of B2B accounts and is an identity map — old-org SFID equals the B2B record ID in all 18,348 pairs, so org-service serves the same ID the B2B org uses. Sync is one-way B2B->old; 995/1,957 active-CCLA companies already resolve, ~962 are the import set. Records the confirmation path: Mindy first confirms EasyCLA orgs can be imported into B2B at all, then whether the import preserves existing old-org SFIDs (working assumption: yes — Path A in linuxfoundation/lfx-self-serve#2750); Eric to answer the B2B->old write-back latency after account creation. Co-Authored-By: Claude Fable 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 7e75b46a1..130c7c179 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -211,6 +211,7 @@ A related unresolved question: how a user's Org Lens is determined for attested 4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. + **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,022 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b`; the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above**: EasyCLA keeps `company_external_id` (old SFIDs) untouched, and anything needing the B2B ID joins through the old-org account — which also means ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,022 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company and **no remap is needed at all** (already proven for the 995 synced active-CCLA companies); if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 996/1,022 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use a newer type (`012QP000002OBQzYAO`) that should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- @@ -266,3 +267,5 @@ Rules applied throughout: exclude `IS_DELETED`/`ISDELETED` and `_FIVETRAN_DELETE The membership gate deliberately applies **no** `ASSET.STATUS` filter, matching member-service's SOQL. The current-vs-lapsed split in §2.2 adds `STATUS IN ('Active','Purchased','At Risk')`; the remaining statuses in the data are `Expired`, `Invoice Cancelled` and `Associate Cancelled`. Re-measured 2026-09-11: the §2.2 and §2.3 totals reproduce exactly, with active-CCLA counts drifting by one row (1,949/809) as DynamoDB grows — the drift the basis note describes. Eric's domain-matching figures are reproducible from [his scripts](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend/scripts); the 374/11 overlap figures in §2.3 come from running his classification and the SFID-presence test over the same rows in one query. + +The crosswalk figures in open item 5 (measured 2026-09-18, read-only) come from the same sources plus the old-org mirror's `SFID_B2B` column: coverage joins `SALESFORCE.ACCOUNT` to `SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` on `LEFT(sfid_b2b, 15) = LEFT(id, 15)`; the one-way-sync finding compares monthly counts of new old-org accounts carrying `sfid_b2b` against monthly B2B account creations; the latency non-finding compares `CREATEDDATE` across the join (delta is 0 minutes for all 1,022 post-carve-out pairs — copied, not independent); EasyCLA coverage extends the §2 company join through `sfid_b2b` to a live B2B row, split by the active-CCLA rule above. Note the Dynamo mirrors store rows as a `DATA` variant column, so company/signature fields are read as `DATA:company_external_id::string` etc. From a52884d1c4e5429e8eabad4f9774cefadd4cef65 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 18 Sep 2026 23:15:29 -0700 Subject: [PATCH 18/23] docs(review): address PR #5210 review feedback from Luis and Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the audit from @luismoriguerra plus 6 open Copilot threads. From @luismoriguerra: - m3-fga-model.md §4.1 (new): Variant B's cla_admin relation on b2b_org would be silently reaped. Verified in code — member-service publishes update_access on create/update/CDC/settings/reindex, and fga-sync's SyncObjectTuples deletes every live tuple not in the desired set, sparing only ExcludeRelations members and team:-prefixed subjects. cla_admin carries user: subjects, so it survives neither. Records the member-service change, deploy order and regression test needed, and the CLA-owned-object alternative. - m3-fga-model.md §4: replaced the "no longer depends on the B2B account decision" side-benefit — the relation is defined on b2b_org, so the org record is a precondition, not an independence. - m3-fga-model.md §7: proposed decision form "B for M3 (conditional on §4.1), A for M5", plus an owner for the ACS re-grant (Path B only) and the FGA-vs-ACS disagreement rule. - Fixed the drifted sign/service.go cite (2949 -> 2941/2953); settled the cla_admin vs cla_manager naming. From copilot-pull-request-reviewer: - m3-fga-model.md §5.2: corrected a factually wrong claim. v4 writes the initiating LFID into signature_acl while SignatureSigned is false (sign/service.go:2941,2953), so a naive projection would grant lens access to a pending CCLA. The projector now gates on signed+approved. - m3-fga-model.md §6: split the write tier. CurrentUserInACL is applied at exactly two sites, both approval-list; CLA-manager create/delete enforce only the project|org tree (cla_manager/handlers.go:65,122). - m3-org-visibility.md §3: made the ID remap conditional on Path A/Path B instead of asserting every new account gets a new SFID. - m3-org-visibility.md open item 5 + appendix: reconciled 1,022 -> 1,045 post-carve-out pairs against a fresh read-only Snowflake query (1,045 B2B accounts, 1,045 join rows, 1,045 distinct old-org rows, exact 1:1); the 1,022 was a stale snapshot. Record types refreshed to 1,019/23/3 — a third type (01241000001E1xlAAC) was not previously recorded. - m3-org-visibility.md §4.1: M4/M5 -> M5 to match the companion model. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 109 ++++++++++++++++-- .../easycla-ss-migration/m3-org-visibility.md | 13 ++- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index 9ae3a8900..fad25bb99 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -39,9 +39,14 @@ One new relation on the existing org type. No new object types in M3. type b2b_org relations ...existing (writer, auditor, ...) - define cla_admin: [user] # working name; "cla_manager" also fits + define cla_admin: [user] ``` +The relation is named **`cla_admin`** throughout this document and that is the name +proposed for the ADR. `cla_manager` was the alternative but is rejected: it collides +with the existing ACS `cla-manager` role, and the two are deliberately not the same +thing — the tuple is projected from `signature_acl`, not from the ACS role (§3). + | Question | Answered by | How | |---|---|---| | Does org X appear in the user's selector? | FGA | user holds any org relation, `cla_admin` now included | @@ -85,22 +90,66 @@ read authorization on the query plane, at M5. Nothing in M3 uses them: tuples are projections — migrating is a re-backfill from `signature_acl`, not a data migration. -**Side benefit**: the relation sits on whatever org object exists, so the M3 model no -longer depends on the open "EasyCLA orgs become B2B Salesforce accounts" decision -([linuxfoundation/easycla#5210](https://github.com/linuxfoundation/easycla/pull/5210)). +**Model shape, not independence**: the relation is defined on a specific object type. +As drafted that is `b2b_org`, which means the model still requires each EasyCLA company +to be represented by a B2B organization — without that record there is nothing to attach +the relation to. The earlier claim that this made M3 independent of the "EasyCLA orgs +become B2B Salesforce accounts" decision +([linuxfoundation/easycla#5210](https://github.com/linuxfoundation/easycla/pull/5210)) +overstated it: what the single relation avoids is new *CLA object types*, not the org +record itself. Treat the B2B org record as a **precondition** of this model. + +### 4.1 Blocking constraint: `b2b_org` tuples are reaped by member-service + +**A `cla_admin` relation placed on `b2b_org` would be silently deleted**, and this must +be resolved before Variant B can be chosen. Verified in code: + +- member-service publishes an `update_access` FGA message on every `b2b_org` create and + update, from three paths — the writer orchestrator, the CDC (Salesforce change feed) + consumer, and the org-settings writer — plus `/admin/reindex` and backfill + ([`internal/service/messaging.go` `BuildB2BOrgFGAMessage`](https://github.com/linuxfoundation/lfx-v2-member-service/blob/main/internal/service/messaging.go)). +- fga-sync treats that message as a **full sync** of the object: `SyncObjectTuples` + reads every live tuple on the object and deletes any not in the desired set + ([`fga.go` `SyncObjectTuples`](https://github.com/linuxfoundation/lfx-v2-fga-sync/blob/main/fga.go)). +- Only two things survive a relation the publisher does not know about: membership in + the message's `ExcludeRelations` list, or a `team:`-prefixed subject. `cla_admin` + tuples carry `user:` subjects, so **neither applies**. The current exclude list is + hardcoded to `parent`, `child`, and conditionally `global_org_admin`, `membership`, + `writer`, `auditor`. + +The failure mode is silent: managers lose lens access on the next unrelated org update, +with no error surfaced anywhere. Making Variant B safe therefore requires a +**member-service change** (add `cla_admin` to `ExcludeRelations` on every publish path), +a **deploy-order constraint** (that change ships before any tuple backfill), and a +**regression test** that an org update preserves CLA tuples. If member-service will not +own that, the manager grant belongs on a CLA-owned object instead — as §5 notes, a single +CLA-owned type in M3 avoids the reaping problem but reopens the "no CLA types before M5" +decision the same way Variant A does. ## 5. Explicitly out of scope for this model Unchanged open items — this proposal solves none of them, under either model shape: 1. **CLA-only view** — UI work to hide membership sections for `cla_admin`-only users. -2. **Designees** — `cla-manager-designee` exists only in ACS, with no `signature_acl` - entry before the CCLA is signed, so no tuple can be projected — and none is needed: - a designee's only job is the pre-signing window (initiate DocuSign), which runs on the - unscoped Sign CLA flow, not inside an org's lens. The moment they have something to - see in the lens (a signed CCLA) is the moment `signature_acl` — and therefore their - `cla_admin` tuple — exists, because v4 writes the initiating designee as the ACL's - sole initial entry ([sign/service.go:2949](../../cla-backend-go/v2/sign/service.go#L2949)). +2. **Designees — and the pre-signing window.** The `cla-manager-designee` role itself + exists only in ACS. But the earlier claim that there is "no `signature_acl` entry + before the CCLA is signed" is **wrong**, and the projection must account for it: v4 + creates the CCLA row with `SignatureSigned: false` and writes the initiating user's + LFID into `SignatureACL` on that same row + ([sign/service.go:2941 and :2953](../../cla-backend-go/v2/sign/service.go#L2941)), + persisting it via `populateSignURL`. A projection keyed on "appears in + `signature_acl`" would therefore grant lens access to a **pending, unsigned** CCLA. + + **Therefore the projector gates on the active state, not on ACL membership alone:** + project a `cla_admin` tuple only while the signature is `signature_signed = true` + **and** `signature_approved = true`, and remove it when the company's last CCLA in + that state exits it. This is the same active-CCLA predicate §2 uses for counting, so + the tuple set and the population figures stay consistent by construction. + + With that gate, the designee case resolves cleanly: a designee's job is the + pre-signing window (initiate DocuSign), which runs on the unscoped Sign CLA flow, not + inside an org's lens, and the moment they have something to see in the lens (a signed + CCLA) is the moment the gate opens for the ACL entry v4 already wrote for them. 3. **Signatory lens access** — no M3 work: signatories have no console access today (email-only DocuSign interaction; the `cla-signatory` ACS role is checked by no endpoint). Proper read access is spec 044's `cla_ccla#signatory` at M5. Do **not** @@ -117,7 +166,19 @@ Reads and writes sit at different widths: | Tier | Gate | Effective scope | |---|---|---| | **Read / list** | `IsUserAuthorizedForOrganization(..., ALLOW_ADMIN_SCOPE)` | **Company-wide** | -| **Write / manage** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)`, then `CurrentUserInACL` on the signature | **Per CLA group**, twice over | +| **Write — approval list / signature** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)`, **then** `CurrentUserInACL` on the signature | **Per CLA group**, twice over | +| **Write — CLA-manager administration** | `IsUserAuthorizedForProjectOrganizationTree(..., DISALLOW_ADMIN_SCOPE)` **only** | **Per CLA group**, once | + +The two write rows are not interchangeable. `CurrentUserInACL` is applied at exactly two +call sites in the backend — both on the approval-list/signature path +([`signatures/service.go:523`](../../cla-backend-go/signatures/service.go#L523), +[`v2/signatures/handlers.go:1499`](../../cla-backend-go/v2/signatures/handlers.go#L1499)). +CLA-manager create and delete enforce the project|organization tree and nothing else +([`v2/cla_manager/handlers.go:65`](../../cla-backend-go/v2/cla_manager/handlers.go#L65), +[`:122`](../../cla-backend-go/v2/cla_manager/handlers.go#L122)), so a manager scoped to a +CLA group can add or remove managers there without being in any signature's ACL. An M5 +parity model must preserve this endpoint-specific difference rather than applying the +stricter ACL rule uniformly. The read tier is company-wide because the shared scope matcher accepts a `project|organization` scope on its **organization half alone**, ignoring the project @@ -197,3 +258,27 @@ giving two systems that can disagree — the parity problem in open item 6 of The spec-044 ADR review (open item 3 in [m3-org-visibility.md](m3-org-visibility.md) §5): adopt this as the M3 model, with the four CLA types moved to the M5 ADR where they become enforcing. + +**Proposed form of the decision**, so the "no CLA types before M5" reopen is explicitly +scoped rather than implied: + +> **Variant B (single `cla_admin` relation) for M3, conditional on §4.1 being resolved; +> Variant A (dedicated CLA types) for M5.** + +The ADR should record three things alongside it: + +1. **The §4.1 owner and resolution** — either member-service accepts the + `ExcludeRelations` change with a deploy-order constraint and regression test, or the + grant moves to a CLA-owned object. Variant B is not safe to build until this is + answered. +2. **An owner for the ACS re-grant.** Under Path B of the org import + ([m3-org-visibility.md](m3-org-visibility.md) open item 5), FGA would admit an + existing manager while v4 returns 403, because their ACS scope is still pinned to the + old company ID. This is currently buried in that open item and needs to be its own + tracked work item. Under Path A (the working assumption — IDs are preserved) the + problem does not arise, which is another reason to settle Path A/B first. +3. **The FGA-vs-ACS disagreement rule**, defined before cutover: what the system does + when FGA lets someone into the lens but ACS refuses the API call. "FGA gates the UI, + ACS gates the APIs" describes the split but does not say which wins, what the user + sees, or how the drift is reported. Tracked as open item 6 in + [m3-org-visibility.md](m3-org-visibility.md) §5. diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 130c7c179..6b6c3cf92 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -118,7 +118,12 @@ flowchart LR 1. **The org record does not exist**, in two layers. **(1a)** 55% of EasyCLA orgs with a real SFID have no account in the B2B Salesforce org — they were left behind in the old platform org during the B2C decouple. No predicate change can surface them; the accounts must be ingested. **(1b)** The 314 that do exist fail the Membership-Asset gate — predicate widening covers exactly these, and nothing else. 2. **The user holds no grant.** Org Lens eligibility is an OpenFGA relation on `b2b_org` / CLA objects. EasyCLA CLA-manager roles live in ACS and Org Service scopes; OpenFGA knows nothing about them. -> **ID remapping is a required work item.** Salesforce cannot create a record with a chosen ID, so every newly created account gets a **new SFID**. Combined with the 374 domain-links and the 530 `lf`-shaped IDs, EasyCLA's stored `company_external_id` will not resolve for most of the affected population. The ingest must produce an old-ID → new-ID map that EasyCLA (or the CLA service's mapping store) applies. Only the 1,362 already present in the B2B org carried their IDs over and need no remap. Eric's proposal supplies the ongoing mechanism — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 5](#5-open-items). +> **ID remapping is a conditional work item — it depends on how the ingest creates accounts.** A standard Salesforce insert cannot choose a record ID, so accounts created that way get a **new SFID** and EasyCLA's stored `company_external_id` stops resolving for the affected population (compounded by the 374 domain-links and the 530 `lf`-shaped IDs, which never resolved). But the B2B→old sync provably *does* preserve record IDs across orgs, so the mechanism the ingest uses decides the outcome: +> +> - **Path A — the import preserves existing SFIDs** (working assumption, pending Mindy's confirmation): no remap, no crosswalk, no EasyCLA or ACS rewrites. Already true today for the 995 synced active-CCLA companies. +> - **Path B — the import mints new B2B IDs**: an old-ID → new-ID map is required, but **only for the ingested set**, applied at the Self Serve bridge rather than by rewriting the EasyCLA DB. +> +> Do not plan identity or scope rewrites until this is settled — see [open item 5](#5-open-items) for the measurements and [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) for the Path A/B decision. Eric's proposal supplies the ongoing mechanism either way — each CCLA organization gets a foreign key to its Salesforce Account ID and participates in future account merges, so the key follows the surviving record, "the part that does not exist today". Ordering and ownership are [open item 5](#5-open-items). --- @@ -162,7 +167,7 @@ flowchart LR ``` - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. -- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M4/M5). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M5 — all four dedicated CLA types land together in that milestone under the companion model, so no intermediate model bump or tuple backfill is required at M4). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. @@ -211,7 +216,7 @@ A related unresolved question: how a user's Org Lens is determined for attested 4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. - **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,022 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b`; the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above**: EasyCLA keeps `company_external_id` (old SFIDs) untouched, and anything needing the B2B ID joins through the old-org account — which also means ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,022 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company and **no remap is needed at all** (already proven for the 995 synced active-CCLA companies); if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 996/1,022 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use a newer type (`012QP000002OBQzYAO`) that should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. + **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,045 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b` (1,045 B2B accounts → 1,045 join rows → 1,045 distinct old-org rows, an exact 1:1); the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above**: EasyCLA keeps `company_external_id` (old SFIDs) untouched, and anything needing the B2B ID joins through the old-org account — which also means ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company and **no remap is needed at all** (already proven for the 995 synced active-CCLA companies); if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- @@ -268,4 +273,4 @@ The membership gate deliberately applies **no** `ASSET.STATUS` filter, matching Eric's domain-matching figures are reproducible from [his scripts](https://github.com/linuxfoundation/lfx-architecture-scratch/tree/main/2026-09-Consolidate-B2B-Backend/scripts); the 374/11 overlap figures in §2.3 come from running his classification and the SFID-presence test over the same rows in one query. -The crosswalk figures in open item 5 (measured 2026-09-18, read-only) come from the same sources plus the old-org mirror's `SFID_B2B` column: coverage joins `SALESFORCE.ACCOUNT` to `SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` on `LEFT(sfid_b2b, 15) = LEFT(id, 15)`; the one-way-sync finding compares monthly counts of new old-org accounts carrying `sfid_b2b` against monthly B2B account creations; the latency non-finding compares `CREATEDDATE` across the join (delta is 0 minutes for all 1,022 post-carve-out pairs — copied, not independent); EasyCLA coverage extends the §2 company join through `sfid_b2b` to a live B2B row, split by the active-CCLA rule above. Note the Dynamo mirrors store rows as a `DATA` variant column, so company/signature fields are read as `DATA:company_external_id::string` etc. +The crosswalk figures in open item 5 (measured 2026-09-18, read-only) come from the same sources plus the old-org mirror's `SFID_B2B` column: coverage joins `SALESFORCE.ACCOUNT` to `SFDC_CONNECTOR_PROD_SALESFORCE.ACCOUNT` on `LEFT(sfid_b2b, 15) = LEFT(id, 15)`; the one-way-sync finding compares monthly counts of new old-org accounts carrying `sfid_b2b` against monthly B2B account creations; the latency non-finding compares `CREATEDDATE` across the join (delta is 0 minutes for all 1,045 post-carve-out pairs — copied, not independent); EasyCLA coverage extends the §2 company join through `sfid_b2b` to a live B2B row, split by the active-CCLA rule above. Note the Dynamo mirrors store rows as a `DATA` variant column, so company/signature fields are read as `DATA:company_external_id::string` etc. From bbe921b25929f993f2c01a0eee7d04539fffd810 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 18 Sep 2026 23:34:49 -0700 Subject: [PATCH 19/23] docs(review): address PR #5210 review feedback (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot[bot]: - m3-fga-model.md §5 item 2: change the cla_admin projection gate from "signed AND approved" to signature_signed alone. InvalidateProjectRecord clears signature_approved without touching signature_acl, and the ACS updater reacts only to ACL diffs, so an approval-gated projection would strand managers while v4/ACS still authorizes them. Documents that the tuple set therefore diverges from §2's active-CCLA population. - m3-fga-model.md §3: match the projection bullet to the corrected gate. - m3-fga-model.md §5 item 3: the signatory item claimed "no M3 work". M3 does ship the signatory flow (spec.md FR-030) and lfx-self-serve#2150 registers an ACS policy on cla-signatory. Narrowed the true claim (no cla_admin tuple; the role is checked by no endpoint *today*) and pointed the open part at the selector-union decision. - m3-fga-model.md §7 item 2: reframe as the bridge ID-translation contract, with the translate vs pass-through fork deciding whether an ACS re-grant owner is needed at all. - m3-org-visibility.md console cutover: "hard cut" superseded by the rollout trackers (epic lfx-self-serve#1968, #2750 step 7's parallel window); preserved the narrower true claim that no live ACS<->FGA dual sync is needed. - m3-org-visibility.md open item 4: make the M3 minimum sequence conditional on the selected variant, and name Variant B's blocking member-service ExcludeRelations change plus its deploy-order constraint. - m3-org-visibility.md open item 5: make the ACS re-grant conditional on the same Path A / translate / pass-through fork instead of asserting it. Resolves 6 review threads. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 88 ++++++++++++++----- .../easycla-ss-migration/m3-org-visibility.md | 22 ++++- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index fad25bb99..f3fb11b52 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -66,9 +66,10 @@ directions ("never grant org-wide read to CLA managers", spec 044 Q4). - **Source**: the CCLA signature's `signature_acl` (DynamoDB) — the synchronous write, same source for backfill and ongoing projection. Never derived from ACS. -- **Projection**: managers of any of an org's CCLAs → one `b2b_org:#cla_admin` tuple - per user × org (deduped across the org's CLA groups). Remove the tuple when the user - leaves the last `signature_acl` of that org. +- **Projection**: managers of any of an org's **signed** CCLAs (`signature_signed = true`; + see §5 item 2 for why `signature_approved` is excluded) → one `b2b_org:#cla_admin` + tuple per user × org (deduped across the org's CLA groups). Remove the tuple when the + user leaves the last such `signature_acl` of that org. - **Cross-check**: the read-only ACS drift report (spec 044 item 22) compares ACS `cla-manager` roles against the tuples; run it periodically, not once — ACS keeps being written by v4 for as long as the bridge exists. @@ -140,20 +141,57 @@ Unchanged open items — this proposal solves none of them, under either model s persisting it via `populateSignURL`. A projection keyed on "appears in `signature_acl`" would therefore grant lens access to a **pending, unsigned** CCLA. - **Therefore the projector gates on the active state, not on ACL membership alone:** - project a `cla_admin` tuple only while the signature is `signature_signed = true` - **and** `signature_approved = true`, and remove it when the company's last CCLA in - that state exits it. This is the same active-CCLA predicate §2 uses for counting, so - the tuple set and the population figures stay consistent by construction. - - With that gate, the designee case resolves cleanly: a designee's job is the + **Therefore the projector gates on `signature_signed`, and on that alone:** project a + `cla_admin` tuple once the signature is `signature_signed = true`, and remove it when + the ACL entry is removed or the signature is deleted. + + **`signature_approved` is deliberately *not* part of the gate**, even though §2's + counting predicate uses it. Invalidation sets `signature_approved = false` and the + `note` field without touching `signature_acl` + ([`signatures/repository.go` `InvalidateProjectRecord`](../../cla-backend-go/signatures/repository.go)), + while the ACS updater reacts only to ACL differences + ([`v2/dynamo_events/signatures.go:366`](../../cla-backend-go/v2/dynamo_events/signatures.go#L366), + which logs "No changes in ACL" and exits otherwise). An approval-gated projection + would therefore strand managers at exactly the wrong moment: v4 still authorizes them + via ACS, but FGA removes their only route into the lens — so they cannot inspect the + invalidated agreement's history or initiate a replacement CCLA. Signed-but-unapproved + is precisely the state in which a manager most needs the surface. + + The consequence is that the tuple set is **not** identical to §2's active-CCLA + population: it is the slightly larger "has ever signed, ACL intact" set. That + divergence is intentional and must be stated wherever the two numbers are compared. + + With this gate, the designee case still resolves cleanly: a designee's job is the pre-signing window (initiate DocuSign), which runs on the unscoped Sign CLA flow, not inside an org's lens, and the moment they have something to see in the lens (a signed CCLA) is the moment the gate opens for the ACL entry v4 already wrote for them. -3. **Signatory lens access** — no M3 work: signatories have no console access today - (email-only DocuSign interaction; the `cla-signatory` ACS role is checked by no - endpoint). Proper read access is spec 044's `cla_ccla#signatory` at M5. Do **not** - fold signatories into `cla_admin` — the tab would offer manager actions v4 rejects. +3. **Signatory lens access** — no `cla_admin` tuple, but *not* "no M3 work". Do **not** + fold signatories into `cla_admin`: the tab would offer manager actions v4 rejects. + Proper read access is spec 044's `cla_ccla#signatory` at M5. + + Two things are nevertheless true of M3 and must not be read away by the paragraph + above. First, **M3 ships the signatory flow**: [`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) + FR-030 puts "CCLA signing initiation (signatory flow, including send-by-email)" in + the parity inventory, and [linuxfoundation/lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) + registers `self_serve_request_corporate_signature:create` with an ACS policy on + `cla-manager-designee` **and `cla-signatory`**. So the role does get an M3 + enforcement point on the write path, even though it gets no FGA relation. Second, + FR-031 governs org-lens access by "CLA-manager/signatory authority", which the + manager-only selector union does not satisfy. + + The unresolved part is therefore **how a signatory reaches the lens at all**, not + whether they may read the agreement once inside (spec 044 computes + `cla_ccla#auditor` as `manager or signatory or …`). That is an open Product + + Architecture decision tracked in + [`m3-org-visibility.md` §4.2](m3-org-visibility.md) and narrowed by §4.5 — if CCLA + signing starts from a dedicated CLA landing page rather than the Org Lens, a + signatory never needs selector access. It is not decided here, and the claim that + the `cla-signatory` ACS role "is checked by no endpoint" describes only today's + code: the two current mentions in + [`v2/sign/handlers.go:153`](../../cla-backend-go/v2/sign/handlers.go#L153) and + [`v2/self_serve_sign/handlers.go:132`](../../cla-backend-go/v2/self_serve_sign/handlers.go#L132) + are error-message strings on an org-service lookup failure, not authorization + checks — and [lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) changes that. 4. **ACS/FGA parity** — FGA gates the UI, ACS enforces the API, for the whole M3→M5 bridge period; both are fed by the same v4 write (`signature_acl` synchronous, ACS role asynchronous), and disagreement handling remains open item 6 there. @@ -271,12 +309,22 @@ The ADR should record three things alongside it: `ExcludeRelations` change with a deploy-order constraint and regression test, or the grant moves to a CLA-owned object. Variant B is not safe to build until this is answered. -2. **An owner for the ACS re-grant.** Under Path B of the org import - ([m3-org-visibility.md](m3-org-visibility.md) open item 5), FGA would admit an - existing manager while v4 returns 403, because their ACS scope is still pinned to the - old company ID. This is currently buried in that open item and needs to be its own - tracked work item. Under Path A (the working assumption — IDs are preserved) the - problem does not arise, which is another reason to settle Path A/B first. +2. **The bridge ID-translation contract — and only then an ACS re-grant owner.** Under + Path B of the org import ([m3-org-visibility.md](m3-org-visibility.md) open item 5) + the lens holds a new B2B SFID while ACS scopes and `company_external_id` still carry + the old one. Whether that needs an ACS migration depends entirely on a contract + nobody has written down yet: + + - **Bridge translates (new → old) before calling v4** — as + [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) + describes: requests still match existing ACS scopes, **no re-grant is needed**, and + the cost is that every bridged path must translate without exception. + - **New ID passed through untranslated** — v4 returns 403 for managers FGA has + already admitted, and an ACS-scope migration with a named owner becomes mandatory. + + **Decide and document the boundary contract first**; assign the ACS re-grant owner + only if the second option is chosen. Under Path A (the working assumption — IDs are + preserved) neither arises, which is a further reason to settle Path A/B first. 3. **The FGA-vs-ACS disagreement rule**, defined before cutover: what the system does when FGA lets someone into the lens but ACS refuses the API call. "FGA gates the UI, ACS gates the APIs" describes the split but does not say which wins, what the user diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 6b6c3cf92..e15bd5ada 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -169,7 +169,9 @@ flowchart LR - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. - **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M5 — all four dedicated CLA types land together in that milestone under the companion model, so no intermediate model bump or tuple backfill is required at M4). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. -- **Console cutover** — hard cut, no parallel operation of the Corporate Console and the Org Lens. That removes the need for a live ACS↔FGA dual sync. The CLA tuples are projected from `signature_acl` for the newly admitted accounts too — same source for the initial backfill and for ongoing projection (spec 044 item 10, the KV projector), so no grant is ever derived from ACS. ACS's only role at cutover is the read-only drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`), which compares the two and is expected to show divergence where ACS is stale rather than to correct anything. +- **Console cutover** — **staged, not a hard cut.** The 2026-09-10 call framed this as a hard cut; the rollout trackers have since superseded that. Epic [linuxfoundation/lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) retires the Console only after production rollout and further validation, and [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) step 7 requires a **few-week parallel window** with both UIs live before decommissioning. What the original point got right is the narrower claim it was making: no live **ACS↔FGA dual sync** is needed, because CLA tuples are projected from `signature_acl` for newly admitted accounts exactly as for existing ones — the same source for initial backfill and ongoing projection (spec 044 item 10, the KV projector), so **no grant is ever derived from ACS**. + + Note the distinction this collapses: ACS is not reduced to reporting. **Every bridged v4 call remains ACS-authorized** for the whole M3→M5 period — that is the "FGA gates the UI, ACS gates the APIs" split. ACS's read-only role is specifically in **validating the FGA projection**: the drift report (section H, item 22 — `--backfill-acs --roles cla --dry-run`) compares ACS `cla-manager` roles against the tuples and is expected to show divergence where ACS is stale, rather than to correct anything. Enforcement and drift-reporting are separate jobs ACS holds at the same time. > **Open question — how a signatory reaches the lens.** Not a permissions gap: spec 044 computes `cla_ccla#auditor` as `manager or signatory or …`, and the agreement's query-plane gate is `#auditor`, so a signatory **can** read the agreement once inside. The gap is the **org selector**, whose union is "org viewer/admin **or** manages an agreement" — manager-only. A signatory holding no `b2b_org` grant therefore has no organization to select, and so never reaches the CLA data they are authorized to read. That does not square with the signatory flow M3 must deliver ([`spec.md`](../../specs/001-easycla-ss-integration-fable/spec.md) FR-030 lists CCLA signing initiation in the parity inventory; FR-031 ties org-lens access to "CLA-manager/signatory authority"). Either the selector union admits `signatory` — with relation-specific screen permissions, so it confers neither manager nor org-wide access — or the signatory flow enters by another route. Rev 5 lists "selector union + CLA-only view" as an open **Product + Architecture** decision, recommending the no-model-change option; the signatory case is not called out within it. For Luis and Eric; not decided here. **See also §4.5** — if CCLA signing starts from a dedicated CLA landing page rather than the Org Lens, as Eric has proposed, a signatory never needs selector access and this question narrows to signatories who must *review* an existing agreement. @@ -213,9 +215,21 @@ A related unresolved question: how a user's Org Lens is determined for attested 1. **Sales-ops approval** — Eric Searcy (LFX architect) → Mindy White (sales ops); the blocker for the catalogue change. The proposal is published and tracked in [lfx-self-serve-ops#16](https://github.com/linuxfoundation/lfx-self-serve-ops/issues/16). **Update 2026-09-17**: met with the Salesforce team; no objection in principle, but they want to confirm internally with Dolan/Stephanie before committing effort this close to renewal season — answer expected the week of 2026-09-21. Three takeaways: (a) they expect LFX to go through an Apex-exposed interface, not direct sObject create access (see §4.1 item 4); (b) they will accept an AI-written contribution to their repo, tested in sandbox, to accelerate the work; (c) they are motivated to close this out before their renewal-season load increases. Note the ask is not only a catalogue change: 55% of EasyCLA orgs need an account **created or domain-linked**, and predicate widening alone covers only the 314 already-present non-member accounts. 2. **Product decision on the CCLA entry point** (§4.5) — does CCLA signing start from the Org Lens, or from a dedicated CLA landing page that creates the org and hands off? Raised by Heather Willson and Eric Searcy on 2026-09-11; David Deal's position is that product should drive these requirements. This is upstream of the selector-union question in §4.2 and can eliminate it. Unowned as of this writing. 3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. -4. **M3 sequencing** — the minimum is the catalogue change, the FGA model plus `cla_ccla` tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency. -5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before `cla_ccla` tuple projection. An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. - **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 returns 403 on every call. The remap must also carry ACS org/project-scope roles forward to the new SFID, and cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the new-SFID endpoints. +4. **M3 sequencing — conditional on which FGA model is selected.** The common minimum is the catalogue change, an FGA model bump plus tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. **What item 9/10 actually project depends on the model decision in [`m3-fga-model.md` §7](m3-fga-model.md):** + - **Variant A (spec 044's dedicated CLA types)** — item 9 adds `cla_group`/`cla_ccla`, item 10 projects `cla_ccla` tuples. No member-service change. + - **Variant B (single `b2b_org#cla_admin` relation, the companion doc's M3 proposal)** — item 9 adds one relation to the existing `b2b_org` type and item 10 projects `b2b_org:#cla_admin` from `signature_acl`. This variant carries **an extra, blocking work item the list above does not name**: member-service must add `cla_admin` to the `ExcludeRelations` of its `update_access` message, or fga-sync's full-sync semantics silently reap every projected tuple on the next org write ([`m3-fga-model.md` §4.1](m3-fga-model.md)). That change must deploy **before** the projector, and it is owned by a different team — so Variant B's sequence is longer than Variant A's despite the smaller model diff. + + The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency under either variant. +5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before CLA tuple projection (`cla_ccla` or `b2b_org#cla_admin`, per item 4). An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. + **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 may return 403 on every call. + + **Whether an ACS migration is actually needed is conditional on the bridge's ID-translation contract, and that contract is the thing to settle first** (the same fork as [`m3-fga-model.md` §7](m3-fga-model.md) item 2): + + - **Under Path A** ([linuxfoundation/lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750), the working assumption) the import preserves existing old-org SFIDs, no ID changes, and none of this arises. + - **Under Path B, if the bridge translates new→old** before calling v4, the ACS scope stays valid as written and **no re-grant is required** — the translation is the migration. + - **Under Path B with untranslated pass-through**, the 403 above is real and an ACS org/project-scope migration to the new SFID becomes mandatory, with a named owner. + + So this is a work item to *scope*, not one to assume. Under whichever branch applies, cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the endpoints for their org. **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,045 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b` (1,045 B2B accounts → 1,045 join rows → 1,045 distinct old-org rows, an exact 1:1); the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above**: EasyCLA keeps `company_external_id` (old SFIDs) untouched, and anything needing the B2B ID joins through the old-org account — which also means ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company and **no remap is needed at all** (already proven for the 995 synced active-CCLA companies); if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. From 60c9931ef88a462fa5cb5a66075d6d6fdce8920d Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Fri, 18 Sep 2026 23:49:41 -0700 Subject: [PATCH 20/23] docs(review): address PR #5210 review feedback (round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot[bot]: - m3-org-visibility.md open item 5: Path A was overstated as "no remap is needed at all" and "valid for every company". That is false for two populations the doc itself measures: the 530 lf-shaped IDs (§2.1), which have no Salesforce record to preserve, and the 374 domain-linked orgs (§2.3), whose target B2B account has a different ID than the stored value. Qualified Path A to the SFID-resolvable subset and kept an explicit rewrite/translation requirement for the other two sets. - m3-fga-model.md §7 item 2: same unqualified Path A claim, same fix. - m3-fga-model.md §4.2 (new): a cla_admin tuple is necessary but not sufficient. Verified in lfx-self-serve that the org selector classifies only writer/auditor from b2b_org_settings, and that the CLA BFF route's requireOrgLensAccess/assertOrgLensRead accepts only a roster grant or b2b_org#auditor — so a cla_admin-only manager appears in no selector and gets a 403. Variant B therefore needs three changes across three repos, not one relation; noted that the CLA-route gate must not widen non-CLA lens access. - Cross-referenced the new constraint from §7's decision line and from the visibility doc's Variant B bullet, so the variant comparison reflects the real cost. Resolves 4 review threads; 2 remaining ask for PR-description changes, which are pending the author's approval. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 41 +++++++++++++++++-- .../easycla-ss-migration/m3-org-visibility.md | 11 ++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index f3fb11b52..8309d0e0a 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -127,6 +127,35 @@ own that, the manager grant belongs on a CLA-owned object instead — as §5 not CLA-owned type in M3 avoids the reaping problem but reopens the "no CLA types before M5" decision the same way Variant A does. +### 4.2 Second blocking constraint: the tuple alone does not reach the consumer + +A `cla_admin` tuple is necessary but **not sufficient** — nothing in Self Serve reads it +today, and two specific gates would still refuse a manager who holds only that tuple. +Verified in `lfx-self-serve` at the time of writing: + +- **The org selector** builds its roster from `b2b_org_settings` `member:` + documents and classifies each entry as **`writer` or `auditor` only** + ([`org-role-grants.service.ts:401-403`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/services/org-role-grants.service.ts#L401-L403)). + A `cla_admin` grant produces no roster entry, so the org never appears in the picker. +- **The CLA BFF route** is guarded by `requireOrgLensAccess` + ([`org-clas.route.ts`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/routes/org-clas.route.ts)), + which delegates to `assertOrgLensRead`. That helper accepts a roster grant **or** a + direct `b2b_org:#auditor` answer from the authorizer, and nothing else + ([`org-lens-read-access.helper.ts`](https://github.com/linuxfoundation/lfx-self-serve/blob/main/apps/lfx-one/src/server/helpers/org-lens-read-access.helper.ts)). + A `cla_admin`-only caller gets a 403. + +So Variant B needs **three** changes, not one: the relation, a selector +discovery/materialization path that surfaces `cla_admin` orgs into the picker, and a +CLA-route-specific `cla_admin` gate **that retains the existing auditor gate for non-CLA +lens routes** — a CLA manager must not thereby acquire read access to meetings, ROI or +the people roster, which is exactly the "never grant org-wide read to CLA managers" +constraint in §2. + +This does not sink Variant B, but it does change the comparison in §7: the "one relation +versus four types" framing understates Variant B's cost, because the consumer-side work +is real and lands in a third repo. It must be weighed against Variant A with these items +included on Variant B's side of the ledger. + ## 5. Explicitly out of scope for this model Unchanged open items — this proposal solves none of them, under either model shape: @@ -300,12 +329,13 @@ four CLA types moved to the M5 ADR where they become enforcing. **Proposed form of the decision**, so the "no CLA types before M5" reopen is explicitly scoped rather than implied: -> **Variant B (single `cla_admin` relation) for M3, conditional on §4.1 being resolved; +> **Variant B (single `cla_admin` relation) for M3, conditional on §4.1 and §4.2 being resolved; > Variant A (dedicated CLA types) for M5.** The ADR should record three things alongside it: -1. **The §4.1 owner and resolution** — either member-service accepts the +1. **The §4.1 owner and resolution** (and, with it, the §4.2 consumer-side work — selector + materialization plus a CLA-route gate, which lands in `lfx-self-serve`) — either member-service accepts the `ExcludeRelations` change with a deploy-order constraint and regression test, or the grant moves to a CLA-owned object. Variant B is not safe to build until this is answered. @@ -324,7 +354,12 @@ The ADR should record three things alongside it: **Decide and document the boundary contract first**; assign the ACS re-grant owner only if the second option is chosen. Under Path A (the working assumption — IDs are - preserved) neither arises, which is a further reason to settle Path A/B first. + preserved) neither arises **for companies that store a real old-org SFID**, which is + a further reason to settle Path A/B first. Path A is not a blanket exemption: the + 530 `lf`-shaped IDs have no Salesforce record to preserve, and the 374 domain-linked + orgs point at a B2B account whose ID differs from the stored value + ([m3-org-visibility.md](m3-org-visibility.md) §2.1 and §2.3). Both sets need a rewrite + or translation under either path, and both therefore still need an owner. 3. **The FGA-vs-ACS disagreement rule**, defined before cutover: what the system does when FGA lets someone into the lens but ACS refuses the API call. "FGA gates the UI, ACS gates the APIs" describes the split but does not say which wins, what the user diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index e15bd5ada..e31a74a07 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -217,7 +217,7 @@ A related unresolved question: how a user's Org Lens is determined for attested 3. **Architecture review of spec 044's four ADRs** — CLA FGA types, dedicated `cla-v1-objects` replica bucket, read-plane-first for an external system of record, and the catalogue boundary + selector rule. Gates the FGA model bump and the member-service PR; rev 5's own recommendation is to approve all four, with #4 being the member-service PR plus a full `b2b_org` reindex. This review is also where the reversal in §6 should be formally recorded, and where the second-order effects in §4.4 need owners. 4. **M3 sequencing — conditional on which FGA model is selected.** The common minimum is the catalogue change, an FGA model bump plus tuple projection/backfill, and the selector union — spec 044 epic items **5** (predicate + `b2b_org_ensure` + full reindex), **9** (`model.fga` v+1), **10** (KV projector) and **13** (selector union + CLA-only persona) — with CLA tabs shipping on the existing bridge. **What item 9/10 actually project depends on the model decision in [`m3-fga-model.md` §7](m3-fga-model.md):** - **Variant A (spec 044's dedicated CLA types)** — item 9 adds `cla_group`/`cla_ccla`, item 10 projects `cla_ccla` tuples. No member-service change. - - **Variant B (single `b2b_org#cla_admin` relation, the companion doc's M3 proposal)** — item 9 adds one relation to the existing `b2b_org` type and item 10 projects `b2b_org:#cla_admin` from `signature_acl`. This variant carries **an extra, blocking work item the list above does not name**: member-service must add `cla_admin` to the `ExcludeRelations` of its `update_access` message, or fga-sync's full-sync semantics silently reap every projected tuple on the next org write ([`m3-fga-model.md` §4.1](m3-fga-model.md)). That change must deploy **before** the projector, and it is owned by a different team — so Variant B's sequence is longer than Variant A's despite the smaller model diff. + - **Variant B (single `b2b_org#cla_admin` relation, the companion doc's M3 proposal)** — item 9 adds one relation to the existing `b2b_org` type and item 10 projects `b2b_org:#cla_admin` from `signature_acl`. This variant carries **an extra, blocking work item the list above does not name**: member-service must add `cla_admin` to the `ExcludeRelations` of its `update_access` message, or fga-sync's full-sync semantics silently reap every projected tuple on the next org write ([`m3-fga-model.md` §4.1](m3-fga-model.md)). That change must deploy **before** the projector, and it is owned by a different team. Variant B additionally needs consumer-side work in `lfx-self-serve` — the selector classifies only `writer`/`auditor`, and the CLA BFF route's gate accepts only a roster grant or `b2b_org#auditor`, so a `cla_admin`-only manager reaches neither ([`m3-fga-model.md` §4.2](m3-fga-model.md)). So Variant B's sequence is longer than Variant A's and spans three repos, despite the smaller model diff. The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency under either variant. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before CLA tuple projection (`cla_ccla` or `b2b_org#cla_admin`, per item 4). An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. @@ -230,7 +230,14 @@ A related unresolved question: how a user's Org Lens is determined for attested - **Under Path B with untranslated pass-through**, the 403 above is real and an ACS org/project-scope migration to the new SFID becomes mandatory, with a named owner. So this is a work item to *scope*, not one to assume. Under whichever branch applies, cutover acceptance should include: an existing non-admin CLA manager, not just a fresh admin, can call the endpoints for their org. - **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,045 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b` (1,045 B2B accounts → 1,045 join rows → 1,045 distinct old-org rows, an exact 1:1); the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above**: EasyCLA keeps `company_external_id` (old SFIDs) untouched, and anything needing the B2B ID joins through the old-org account — which also means ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company and **no remap is needed at all** (already proven for the 995 synced active-CCLA companies); if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. + **Update 2026-09-18: the map already exists as live data — the old org's `Account.sfid_b2b` crosswalk.** Measured in Snowflake (appendix): every B2B account, including all 1,045 created since the 2026-06 carve-out, is referenced by exactly one old-org account's `sfid_b2b` (1,045 B2B accounts → 1,045 join rows → 1,045 distinct old-org rows, an exact 1:1); the pointer is never stale (every one resolves to a live B2B account). More than a pointer, it is an **identity**: in 100% of pairs (17,303 carve-out + 1,045 post-carve-out) the old-org account's own SFID equals its `sfid_b2b` — the sync preserves record IDs across orgs (which also explains the copied `CreatedDate`s), so for every synced account the old-org SFID, the org-service SFID, and the B2B record ID are the same string. Org-service already reads and exposes it as `SalesforceB2BAccountID`, with a `hasb2baccountid` filter ([organization-service `organization/repository.go`](https://github.com/LF-Engineering/organization-service/blob/main/organization/repository.go), the `sa.sfid_b2b` column). So the answer to "where does the map live" may be **none of the three candidates above** — **but only for the subset this actually covers.** For a company whose stored `company_external_id` *is* a real old-org SFID, EasyCLA keeps it untouched and anything needing the B2B ID joins through the old-org account, so ACS scopes never need carrying forward and the Corporate Console keeps working unchanged for as long as it runs. + + **Two populations are outside that guarantee and still need an explicit mapping or remediation path, whatever the ingest does with IDs:** + + - **The 530 `lf`-shaped IDs (§2.1).** There is no Salesforce record to preserve, so ID preservation is vacuous for them. `sfid_b2b` proves identity only where an old-org account exists *and* EasyCLA stores that account's SFID; neither holds here. They need an account **created** and `company_external_id` **rewritten** — they are invisible to any SFID-keyed remediation. + - **The 374 domain-linked orgs (§2.3).** These link to an existing B2B account whose ID differs from the one EasyCLA stores, so the stored value cannot address the target account no matter which path the ingest takes. They need a translation or a rewrite. + + Path A therefore removes the remap for the *SFID-resolvable* population, not for the whole affected set. The mapping work item shrinks; it does not disappear. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company **that stores a real old-org SFID** (already proven for the 995 synced active-CCLA companies) and no remap is needed for that population — the 530 `lf`-shaped IDs and 374 domain-links still need rewriting either way; if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. 6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. --- From ff65f61475ed19523c5ab6357f22db8199ae4b8e Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 21 Sep 2026 11:54:10 -0700 Subject: [PATCH 21/23] docs(review): record the M5 read-model tension raised in re-audit Address PR #5210 review feedback from @luismoriguerra (2026-09-21 re-audit): - m3-fga-model.md section 6: record that neither variant carries the company-wide read Product confirmed into M5. Spec 044's cla_ccla#auditor is per-agreement, so a manager of one CLA group gets no read on a sibling group without b2b_org#auditor, which m3-org-visibility.md section 4.2 forbids for CLA managers. Variant B preserves today's behaviour in M3 via the bridge but defines no M5 read model. - m3-fga-model.md section 6: size the gap. 318 orgs hold more than one signed CCLA group; within those, 849 of 1,033 manager x org pairs (82%) manage only a subset. Re-derived read-only against the prod mirror; Luis measured 287/815/83% with different filters, same conclusion. - m3-fga-model.md section 6: record the per-Salesforce-org grain, citing GetCompaniesByExternalID at v2/company/service.go:1325, and the proposed resolution (a per-company CLA read relation, on b2b_org or a CLA-owned object). - m3-fga-model.md section 7: note the A-vs-B framing may be the wrong shape of question. Recommendation left standing as the starting position. - m3-org-visibility.md open item 4: add the one-way B2B->old sync sequencing note, since Console-created companies never reach B2B during the parallel window. - m3-org-visibility.md section 4.2: cross-reference the new section 6 text. Records the finding for the spec-044 ADR review rather than resolving it; the reviewer approved the PR as a proposal and did not consider it a merge blocker. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 47 +++++++++++++++++++ .../easycla-ss-migration/m3-org-visibility.md | 4 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index 8309d0e0a..f289f4b82 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -301,6 +301,45 @@ is deliberate, M5's per-agreement read model must **preserve** it rather than na `#auditor` has to keep admitting a company's non-manager CLA admins to read its other CLA groups. Carry this into the M5 ADR as a requirement on the model, not an open item. +**This requirement is in tension with Variant A's read model, and that tension is +unresolved.** Raised by Luis Moriguerra in review (2026-09-21). Spec 044 computes +`cla_ccla#auditor` as `manager or signatory or auditor from b2b_org or auditor from +cla_group` — a **per-agreement** relation +([m3-org-visibility.md](m3-org-visibility.md) §4.2). A manager of CLA group X therefore +gets no read on the same company's sibling group Y unless they hold `b2b_org#auditor`, +which §4.2 of that document explicitly forbids for CLA managers ("Never org-wide read for +CLA managers"). So Variant A as written does not satisfy the requirement above, while +Variant B preserves today's behaviour in M3 through the bridge but defines no M5 read +model at all. Neither variant, as currently specified, carries company-wide read into M5. + +**The gap is not marginal.** Measured read-only against the prod DynamoDB mirror +(2026-09-21, appendix method): **318 organizations hold more than one signed CCLA group**, +and within those, **849 of 1,033 manager × org pairs (82%) manage only a subset of their +organization's groups**. Under Variant A as written, that 82% loses visibility it has +today. Luis measured 287 orgs and 815 of 980 pairs (83%) with slightly different filters; +the two derivations agree on the conclusion. + +**Grain: per Salesforce org, not per EasyCLA company row.** `GetCompanyClaGroups` resolves +its rows through `GetCompaniesByExternalID(ctx, companySFID, true)` +([`v2/company/service.go:1325`](../../cla-backend-go/v2/company/service.go#L1325)), which +fans out over every company record sharing that SFID — so a single Salesforce org can +carry multiple signing entities with divergent ACLs. Any per-company read relation has to +be keyed on the SFID to match what v4 returns today. Luis counts 4 SFIDs carrying multiple +signing entities, 3 with divergent ACLs across 19 groups. + +**Proposed resolution, for the ADR.** Luis's conclusion is that a **per-company CLA read +relation is needed in both M3 and M5**, which reframes §7's question from "Variant A or +Variant B" to *where that relation lives*: + +- **On `b2b_org`** (`cla_admin`, extended as `... or cla_admin from b2b_org`) — carries + §4.1's member-service coupling forward into M5. +- **On a CLA-owned object** (`cla_company#manager`, extended as `... or manager from + cla_company`) — no member-service coupling, but adds a type and reopens P2's "no CLA + object types before M5" on its own terms. + +Not decided here. This is for the spec-044 ADR review (§7), which is the venue that can +settle it. + **Rejected alternative: filter the list per CLA group in v4.** Considered — the response already carries `claManagers` per row from `signature_acl` ([`v2/company/service.go:1522`](../../cla-backend-go/v2/company/service.go#L1522)), so @@ -332,6 +371,14 @@ scoped rather than implied: > **Variant B (single `cla_admin` relation) for M3, conditional on §4.1 and §4.2 being resolved; > Variant A (dedicated CLA types) for M5.** +**This form may be the wrong shape of question.** §6 records a tension neither variant +resolves: Variant A's per-agreement read model does not preserve the company-wide read +Product has confirmed as intended, and Variant B defines no M5 read model at all. If the +ADR accepts that a per-company CLA read relation is needed in both milestones, the +decision becomes *where that relation lives* — on `b2b_org` or on a CLA-owned object — +rather than a choice between the two variants. The recommendation above stands as the +starting position; §6 states what would displace it. + The ADR should record three things alongside it: 1. **The §4.1 owner and resolution** (and, with it, the §4.2 consumer-side work — selector diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index e31a74a07..5d9b9a6a9 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -167,7 +167,7 @@ flowchart LR ``` - **Org catalogue** — member-service widens its `b2b_org` predicate from "has a Membership Asset" to "membership **or** CLA-referenced", plus an `lfx.member.b2b_org_ensure` request so an unknown account can be onboarded on demand, then a full `b2b_org` reindex. member-service remains the single Salesforce org service. -- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M5 — all four dedicated CLA types land together in that milestone under the companion model, so no intermediate model bump or tuple backfill is required at M4). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6. +- **Permissions** — new FGA types per spec 044: `cla_group`, `cla_ccla` (`manager`, `signatory`), `cla_ecla`, `cla_icla`. Confirmed on the call as needed **in this milestone**, regardless of where the data plane lands. Manager grants derive from the signature row's `signature_acl` (the synchronous write), not from ACS; ACS roles feed a dry-run drift report only. Org admin ≠ CLA manager. **A simpler M3 slice is proposed in [m3-fga-model.md](m3-fga-model.md)**: one `b2b_org#cla_admin` relation and no new object types until the types actually enforce (M5 — all four dedicated CLA types land together in that milestone under the companion model, so no intermediate model bump or tuple backfill is required at M4). Both are on the table for the ADR review (§5 item 3). Under either shape, v4's own two-tier enforcement is replicated unchanged for M3 — company-wide reads, per-agreement writes. Product has confirmed the company-wide read is intended and must be preserved at M5, not narrowed; see [m3-fga-model.md](m3-fga-model.md) §6 — which also records that **neither variant as currently specified carries that guarantee into M5**, since spec 044's `cla_ccla#auditor` is per-agreement while the read it must preserve is company-wide. - **Lens entry** — the org selector becomes a union: org read (`writer`/`auditor` on `b2b_org`) **or** `manager` on any `cla_ccla`, with a CLA-only view for managers who hold nothing else. Never org-wide read for CLA managers. - **Console cutover** — **staged, not a hard cut.** The 2026-09-10 call framed this as a hard cut; the rollout trackers have since superseded that. Epic [linuxfoundation/lfx-self-serve#1968](https://github.com/linuxfoundation/lfx-self-serve/issues/1968) retires the Console only after production rollout and further validation, and [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) step 7 requires a **few-week parallel window** with both UIs live before decommissioning. What the original point got right is the narrower claim it was making: no live **ACS↔FGA dual sync** is needed, because CLA tuples are projected from `signature_acl` for newly admitted accounts exactly as for existing ones — the same source for initial backfill and ongoing projection (spec 044 item 10, the KV projector), so **no grant is ever derived from ACS**. @@ -219,6 +219,8 @@ A related unresolved question: how a user's Org Lens is determined for attested - **Variant A (spec 044's dedicated CLA types)** — item 9 adds `cla_group`/`cla_ccla`, item 10 projects `cla_ccla` tuples. No member-service change. - **Variant B (single `b2b_org#cla_admin` relation, the companion doc's M3 proposal)** — item 9 adds one relation to the existing `b2b_org` type and item 10 projects `b2b_org:#cla_admin` from `signature_acl`. This variant carries **an extra, blocking work item the list above does not name**: member-service must add `cla_admin` to the `ExcludeRelations` of its `update_access` message, or fga-sync's full-sync semantics silently reap every projected tuple on the next org write ([`m3-fga-model.md` §4.1](m3-fga-model.md)). That change must deploy **before** the projector, and it is owned by a different team. Variant B additionally needs consumer-side work in `lfx-self-serve` — the selector classifies only `writer`/`auditor`, and the CLA BFF route's gate accepts only a roster grant or `b2b_org#auditor`, so a `cla_admin`-only manager reaches neither ([`m3-fga-model.md` §4.2](m3-fga-model.md)). So Variant B's sequence is longer than Variant A's and spans three repos, despite the smaller model diff. + **Sequencing note — the B2B→old sync is one-way.** Companies created through v4 or the Corporate CLA Console land in the old platform org and never propagate to B2B, so they acquire no `b2b_org` object and cannot appear in the lens until the ingest covers them. While both UIs run in parallel ([lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750) step 7), Console-created companies keep arriving on the wrong side of that sync — so the ingest is not a one-shot backfill that can run before cutover and be done. Either Console company-creation is closed off early, or the ingest repeats until it is. Raised by Luis Moriguerra, 2026-09-21. + The full read-plane migration (search projections, activity log, My CLAs on the service) follows behind parity-gated flags and is not an M3 dependency under either variant. 5. **SFID remap: ordering, scope, and acceptance check.** The old-ID → new-ID map has a required position in the sequence: for every org newly created, domain-linked, or carrying an `lf`-shaped ID, the remap must be applied **before** `b2b_org_ensure`, before query-service indexing, and before CLA tuple projection (`cla_ccla` or `b2b_org#cla_admin`, per item 4). An SFID-scoped API returns an empty result for an unknown company rather than an error, so an unapplied map **fails silently** — the org simply stays invisible with no signal. Eric's proposal supplies the ongoing mechanism, but ownership of producing and applying the map is unassigned, as is whether the key *is* `company_external_id`, a new EasyCLA field, or the spec-044 mapping store. Acceptance check before selector cutover: an old `company_external_id` resolves to the new SFID and the org appears in the lens. **A fourth system needs the map, not just three.** Existing CLA-manager ACS roles are created against `company.CompanyExternalID` at grant time ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144), which calls `assignCLAManager` → [`orgService.CreateOrgUserRoleOrgScopeProjectOrg(..., companySFID, ...)`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L205)), while every API call authorizes against the SFID the caller passes in the request ([`v2/company/handlers.go:134`](../../cla-backend-go/v2/company/handlers.go#L134), `IsUserAuthorizedForOrganization(ctx, authUser, params.CompanySFID, ...)`). After an org's SFID changes, an existing manager's ACS scope stays pinned to the old SFID: FGA admits them to the lens (their `cla_admin` tuple re-projects fine from `signature_acl`), but v4 may return 403 on every call. From 79cbe4aa029215170869c37289b401a6d7398083 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 21 Sep 2026 12:10:59 -0700 Subject: [PATCH 22/23] docs(review): close the signed->unsigned projector gap Address PR #5210 review feedback from copilot[bot] (round 4): - m3-fga-model.md section 5: the signed-only gate missed a transition. ActivateSignature sets signature_approved=true and signature_signed=false in one expression without touching signature_acl (signatures/repository.go:5749-5760, called from v2/gitlab_organizations/service.go:889), so a signed CCLA can go unsigned with its ACL intact. An ACL-diff-driven projector sees no change and strands a cla_admin grant on an unsigned CCLA. Removal condition now includes signature_signed reverting to false, and the text states the projector must watch the attribute rather than only the ACL. - m3-fga-model.md section 4: the M5 cost statement said the per-agreement types replace cla_admin. Section 6 concludes company-wide read needs a per-company relation at M5 too, so they may extend cla_admin or replace it with cla_company instead. Recorded as part of the open question rather than as a settled replacement. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-fga-model.md | 29 +++++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/easycla-ss-migration/m3-fga-model.md b/docs/easycla-ss-migration/m3-fga-model.md index f289f4b82..6ba7fe13f 100644 --- a/docs/easycla-ss-migration/m3-fga-model.md +++ b/docs/easycla-ss-migration/m3-fga-model.md @@ -86,10 +86,15 @@ read authorization on the query plane, at M5. Nothing in M3 uses them: | `cla_group` | M5 — project lens does not read CLA data through FGA before then | | `cla_ecla`, `cla_icla` | M5 — Me lens works today on in-handler ownership checks, no tuples | -**Cost**: a second FGA model bump at M5, when the per-agreement types replace -`cla_admin`. Acceptable because the M5 bump happens under spec 044 regardless, and the -tuples are projections — migrating is a re-backfill from `signature_acl`, not a data -migration. +**Cost**: a second FGA model bump at M5, when the per-agreement types land. Acceptable +because the M5 bump happens under spec 044 regardless, and the tuples are projections — +migrating is a re-backfill from `signature_acl`, not a data migration. + +Note this is **not** settled as a straight replacement. §6 concludes that company-wide +read has to survive into M5, which needs a per-company relation there too — so the +per-agreement types may **extend** `cla_admin` (`... or cla_admin from b2b_org`) rather +than replace it, or replace it with a CLA-owned `cla_company`. Which of those the M5 +model does is part of the open question in §7, and it changes the migration. **Model shape, not independence**: the relation is defined on a specific object type. As drafted that is `b2b_org`, which means the model still requires each EasyCLA company @@ -172,7 +177,21 @@ Unchanged open items — this proposal solves none of them, under either model s **Therefore the projector gates on `signature_signed`, and on that alone:** project a `cla_admin` tuple once the signature is `signature_signed = true`, and remove it when - the ACL entry is removed or the signature is deleted. + the ACL entry is removed, the signature is deleted, **or `signature_signed` goes back + to `false`**. + + **That last condition is load-bearing, and an ACL-diff-driven projector would miss + it.** `ActivateSignature` sets `signature_approved = true` and + `signature_signed = false` in one expression, leaving `signature_acl` untouched + ([`signatures/repository.go:5749-5760`](../../cla-backend-go/signatures/repository.go#L5749-L5760), + called from + [`v2/gitlab_organizations/service.go:889`](../../cla-backend-go/v2/gitlab_organizations/service.go#L889)). + So a signed CCLA can transition to unsigned with its ACL fully populated. A projector + that only reacts to ACL differences — the shape the ACS updater uses — sees no change + and strands a `cla_admin` grant on a CCLA that is no longer signed, which is exactly + what this section says must not happen. **The projector must watch the + `signature_signed` attribute itself, not just the ACL.** Raised by copilot[bot] in + review. **`signature_approved` is deliberately *not* part of the gate**, even though §2's counting predicate uses it. Invalidation sets `signature_approved = false` and the From df41e3a739fe8242b7e9f3a642e7b2ddd06b7794 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 21 Sep 2026 12:22:42 -0700 Subject: [PATCH 23/23] docs(review): correct the FGA/ACS parity failure mode Address PR #5210 review feedback from copilot[bot]: - m3-org-visibility.md open item 6: "different inputs" was wrong. The ACS CLA-manager role is granted from the same DynamoDB event on signature_acl (v2/dynamo_events/cla_manager.go:144) that the tuple projection keys on, so both derive from one source. They diverge because they are two independent asynchronous projections into separate stores - either can lag, fail, or be reaped without the other noticing. The old phrasing obscured the drift the parity rule actually has to handle. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/easycla-ss-migration/m3-org-visibility.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/easycla-ss-migration/m3-org-visibility.md b/docs/easycla-ss-migration/m3-org-visibility.md index 5d9b9a6a9..249bf3050 100644 --- a/docs/easycla-ss-migration/m3-org-visibility.md +++ b/docs/easycla-ss-migration/m3-org-visibility.md @@ -240,7 +240,7 @@ A related unresolved question: how a user's Org Lens is determined for attested - **The 374 domain-linked orgs (§2.3).** These link to an existing B2B account whose ID differs from the one EasyCLA stores, so the stored value cannot address the target account no matter which path the ingest takes. They need a translation or a rewrite. Path A therefore removes the remap for the *SFID-resolvable* population, not for the whole affected set. The mapping work item shrinks; it does not disappear. Coverage today: 995 of 1,957 active-CCLA companies (51%) resolve old SFID → `sfid_b2b` → live B2B account; the 962 that don't are the ingest set, and 18 active-CCLA companies point at SFIDs absent even from the old org (cleanup input). Two caveats: (a) the sync is **one-way, B2B→old** — of new old-org accounts since June, only the ~30–42%/month that are shadow rows of B2B-created accounts have `sfid_b2b`; accounts created in the old org (which is what v4's `CreateOrg` and both Corporate Console creation paths mint) never flow to B2B on their own, so newly signed CCLA companies stay invisible in the lens until the ongoing ingest mechanism sweeps them or v4 creates B2B accounts directly; (b) the shadow rows' `CreatedDate` is copied verbatim from the B2B record (identical to the second on all 1,045 pairs), so replication latency and mechanism are unmeasurable from the data. Confirmation path (status 2026-09-18): **Mindy is first confirming whether EasyCLA orgs can be imported into the B2B org at all**; once that lands, the follow-up is whether the import can create B2B accounts **preserving the companies' existing old-org SFIDs as the B2B record IDs**, the way the ongoing sync does (standard Salesforce inserts cannot choose record IDs, so the sync mechanism is special) — if yes, `company_external_id` stays valid in the B2B org for every company **that stores a real old-org SFID** (already proven for the 995 synced active-CCLA companies) and no remap is needed for that population — the 530 `lf`-shaped IDs and 374 domain-links still need rewriting either way; if the import mints new B2B IDs instead, a remap/crosswalk is needed only for the ingested set. **Working assumption while we wait: IDs are preserved (Path A in [lfx-self-serve#2750](https://github.com/linuxfoundation/lfx-self-serve/issues/2750)).** Separately, ask Eric what the B2B→old write-back latency is after a new B2B account is created — it gates switching v4 org creation to the B2B path. Record-type check: 1,019/1,045 shadow rows share the record type of the accounts EasyCLA resolves today (`01241000000bkf1AAA`); 23 use `012QP000002OBQzYAO` and 3 use `01241000001E1xlAAC`, both of which should be confirmed against org-service's `ORG_SERVICE_RECORD_TYPE_ID` allow-list. -6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. Different inputs, so they can disagree in both directions: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. +6. **Bridge/FGA parity before cutover.** Through M3 the CLA tabs are served by v4 (enforcing via ACS) while lens entry is decided by FGA tuples projected from `signature_acl`. **Both ultimately derive from the same `signature_acl` write** — the ACS CLA-manager role is granted from the same DynamoDB event ([`v2/dynamo_events/cla_manager.go:144`](../../cla-backend-go/v2/dynamo_events/cla_manager.go#L144)) that the tuple projection keys on. They can still disagree in both directions, because they are **two independent asynchronous projections into separate stores**: either can lag, fail, or be reaped (§4.1 of [m3-fga-model.md](m3-fga-model.md)) without the other noticing. That is the failure mode the parity rule has to handle — drift between two copies of one source, not two systems reading different inputs. Concretely: a user passing the FGA selector but failing the v4 ACS check sees an empty or erroring tab; a user passing ACS but missing a tuple never reaches the lens. Spec 044's drift report covers detection; still needed is the required parity *behavior* at cutover — which side wins, and what divergence is acceptable when the flags flip. ---