From 9249258fb54cdc055a4ca8e27df5a418cf2a669d Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 12:14:01 -0700 Subject: [PATCH 01/11] docs: M3 org lens status matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the target status model for the Org lens EasyCLA module once the Corporate CLA Console moves into LFX Self Serve, following the structure of the shipped M2 My CLAs matrix. Covers the two surfaces that carry a status — the CLA entry (Signed / Revoked / Not started) and the employee acknowledgment row (Authorized / Not Authorized / Invalidated) — with a backend-basis column per status, a cross-lens naming map for the states named differently in the Me lens, and the gaps that have no backend behind them yet. Notable findings recorded as gaps: no coverage verdict is computed per acknowledgment row, so the prototype's recoverable "Not Authorized" is nearly unreachable (approval-criteria removals invalidate immediately); the employee signature query applies no signed/approved filter, unlike the CCLA list query, so unsigned rows must be dropped client-side; and invalidation metadata is stored but not exposed on the row model. Signed-off-by: Michal Lehotsky Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 197 ++++++++++++++++++++++++++++++ docs/MY_CLAS_STATUS_MATRIX.md | 1 + 2 files changed, 198 insertions(+) create mode 100644 docs/M3_ORG_LENS_STATUS_MATRIX.md diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md new file mode 100644 index 000000000..e0af2edd0 --- /dev/null +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -0,0 +1,197 @@ +# M3 Status Matrix — Org lens CLA statuses + +Copyright The Linux Foundation and each contributor to CommunityBridge. + +SPDX-License-Identifier: CC-BY-4.0 + +**Parent spec**: milestone brief [03-milestone-ccla-org-lens-fable.md](../specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md); backend endpoints [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md) +**Companion**: [MY_CLAS_STATUS_MATRIX.md](MY_CLAS_STATUS_MATRIX.md) — the shipped M2 status model for the **Me** lens +**Status of this document**: **target model, not shipped.** Written against the M3 UI prototype and the M3 backend as it stands on `dev` (2026-09-14). + +Single source of truth for the statuses the **Org lens** EasyCLA module shows once the +Corporate CLA Console moves into LFX Self Serve. Two surfaces carry a status: the **CLA +entry** (one signing entity × CLA group) and each **employee acknowledgment** row beneath +it. Every status below names its **backend basis** — the field or computation it reads — +because several statuses in the prototype have no backend basis yet. +[Not yet implemented](#not-yet-implemented) lists those gaps, so nobody builds to them by +mistake. + +Where this document and the current Corporate CLA Console disagree, the console is the +old behavior and this document is the intent — see +[What changes from today's console](#what-changes-from-todays-console). + +## CLA entry statuses + +Shown on the CLA group card and on its detail page header. + +| Status | What it means | Backend basis | Dated? | +|---|---|---|---| +| **Signed** | The signing entity has a corporate agreement in force for this CLA group. | `signed = true` on the list entry | yes — *Signed by {name} on {date}*, from `signedBy` + `signedOn` | +| **Revoked** | The signing entity is under a sanctions block, so the agreement cannot be relied on and every write is refused. Set by the system, never by a person in the product. | `sanctioned = true` on the list entry | no — see [Not yet implemented](#not-yet-implemented) | +| **Not started** | No agreement exists for this CLA group yet. | none — **not a listable state**, see below | no | + +Rules that hold across the table: + +- **Only signed agreements are listed.** `GET /v4/company/external/{companySFID}/cla-groups` + reads its signatures through a query that filters on `signature_signed = true` **and** + `signature_approved = true`, so an unsigned or invalidated corporate agreement produces + no entry at all. This mirrors M2's rule that unsigned agreements are never shown. A + consequence: `signed` is always `true` on a returned entry, so it is a sanity check + rather than a discriminator. +- **Not started exists only as a preview.** It is reachable solely through the *Sign CLA* + catalog search: picking a CLA group the company has not signed opens a transient detail + page whose managers and approval tabs are locked. It is never a row in the org's CLA + list, and a reload without the search loses it. Treating it as a persisted status would + imply the API can return it. +- **Revoked wins over Signed.** A sanctioned signing entity with a signed agreement shows + **Revoked**. It is the more consequential fact and the more restrictive status — the + entry becomes read-only — so it takes precedence, exactly as in M2. +- **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" + remain banned copy**. Both rules carry over from M2 unchanged. +- **A date is shown only when a real one was recorded.** `signedBy` is omitted when the + CCLA carries no `SignatoryName`, and there is no CLA-manager fallback — the line then + degrades to *Signed on {date}* rather than naming the wrong person. + +### Which status a CLA entry gets + +Read top to bottom; the first matching row wins. + +| Condition | In the data | Status | +|---|---|---| +| No signed+approved CCLA | not returned by the list endpoint | *entry does not exist* | +| Signing entity is sanctioned | company `is_sanctioned = true` → `sanctioned = true` | **Revoked** | +| A corporate agreement is in force | `signed = true` | **Signed** | +| Reached only via the *Sign CLA* catalog | — | **Not started** (preview only) | + +A consequence worth stating plainly: a signing entity that is **sanctioned and has never +signed** cannot appear anywhere in the org's CLA list, because the list is built from +signatures. The prototype shows exactly this case (a sanctioned CLA group with no +agreement), and it is only reachable through the catalog preview. The catalog preview +must therefore apply the sanctions check itself rather than inheriting it from a list +entry that does not exist. + +## Acknowledgment statuses + +Shown per contributor row in the acknowledgments (employee CLA) table of a CLA entry. + +| Status | What it means | Backend basis | Dated? | +|---|---|---|---| +| **Authorized** | The employee acknowledged the corporate agreement and the company's approval criteria still cover them. | `signatureSigned = true` **and** `signatureApproved = true` | no | +| **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked their access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back to the approval list restores them. | **none today** — no coverage verdict is computed for this row, see [Not yet implemented](#not-yet-implemented) | no | +| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. | `signatureApproved = false` | yes — *Invalidated · date* (field not exposed on this row today) | + +Rules: + +- **Unsigned acknowledgments should never be shown** — a row should exist only once the + employee has acknowledged, `signatureSigned = true`, aligning the org lens with M2's + filter and retiring today's console state **"Not set up"** (`!approved && !signed`). + **This is a proposal, not current behavior**: unlike the CCLA query, the employee + signature query filters only on company and project, so unsigned records *are* returned + today and the frontend must drop them. See + [Not yet implemented](#not-yet-implemented). +- **Not Authorized is amber and recoverable; Invalidated is red and deliberate.** The + distinction is the whole point of splitting them: one is a coverage drift the company + can undo, the other is a voided agreement it cannot. Copy must not blur them. +- **Invalidated does not name who did it** unless the record actually says so. New + invalidations store `InvalidationMetadata` (`InvalidatedBy`, `Reason`) and the M3 + invalidate endpoint takes a `reason` enum, but older records carry nothing — so + attribution is per-record, never assumed. Same constraint as M2, with a narrower blast + radius. + +### Which status an acknowledgment row gets + +| Condition | In the data | Status | +|---|---|---| +| Not acknowledged | `signatureSigned = false` | *row is not shown* (filtered client-side today) | +| The acknowledgment was voided | `signatureApproved = false` | **Invalidated** | +| Acknowledgment intact, criteria do not cover the contributor | *not computed today* | **Not Authorized** | +| Acknowledgment intact and covered | `signatureApproved = true` | **Authorized** | + +### Why Not Authorized is nearly unreachable today + +This is the central gap in the M3 status model, and it is worth being precise about. + +Removing an approval criterion **invalidates matching acknowledgments immediately**. +`invalidateSignatures` in `cla-backend-go/signatures/repository.go` sets +`signature_approved = false` with invalidation metadata for removals of email, email +domain, GitHub username, GitHub org, and GitLab username criteria — each guarded by a +`userStillApproved` veto so a contributor covered by another criterion is left alone. The +row therefore lands in **Invalidated**, not in a lingering recoverable state. + +So a genuine **Not Authorized** can only arise from: + +- **GitLab group removals**, which are not handled at all (the same gap M2 records) — + group membership cannot be checked without per-group tokens. +- **Membership drift** — a contributor leaving an approved GitHub org or GitLab group, + which changes coverage without any approval-list edit and is never re-evaluated. +- **Records the invalidation sweep skipped**, e.g. where the underlying user record is + gone. + +The prototype's recovery hint — *add the user to the Approval list, or Invalidate to +remove for good* — is only truthful for those cases. For the common case (a manager +removes a criterion) re-adding the criterion does **not** restore the row, because the +acknowledgment was already voided. Either the row needs a live coverage verdict from the +backend, or removal must stop auto-invalidating. This is an open product decision, not a +copy fix. + +## Cross-lens naming map + +The same underlying state is named differently in the Me lens and the Org lens. That is +deliberate — a contributor reads "what do I need to do", an org admin reads "is this +person covered" — but support needs to translate between them. + +| Underlying state | Me lens (M2, shipped) | Today's Corporate Console | Org lens (M3, target) | +|---|---|---|---| +| Acknowledged and covered | **Valid** | Authorized | **Authorized** | +| Acknowledged, not covered by criteria | **Needs attention** | Not Authorized | **Not Authorized** | +| Acknowledgment voided | **Invalidated** | Not Authorized *(collapsed)* | **Invalidated** | +| Coverage could not be determined | **—** (dash) | *never determined — no coverage check runs* | *no equivalent — see gaps* | +| Company under sanctions | **Revoked** (on the ECLA row) | "Unable to Sign" error state | **Revoked** (on the CLA entry) | +| Not acknowledged / not signed | *row hidden* | Not set up | *row hidden* | + +Two divergences are load-bearing: + +- **"Needs attention" ≡ "Not Authorized".** Same state, different audience. The Me-lens + wording is an instruction to the contributor; the org-lens wording is a fact about a + person. Neither name is being changed. +- **"Revoked" sits on a different object in each lens.** In the Me lens it labels one + contributor's ECLA; in the Org lens it labels the CLA entry for a whole signing entity. + The sanction is a company-level fact in both cases — only the object carrying the label + differs. + +## What changes from today's console + +| Surface | Corporate CLA Console today | Org lens (M3, target) | +|---|---|---| +| Acknowledgment statuses | Authorized / Not Authorized / Not set up, from two booleans | Authorized / Not Authorized / Invalidated; unsigned rows dropped rather than labelled | +| Voided vs not-covered | Indistinguishable — both read "Not Authorized" | Split, with different colors, copy and recoverability | +| CLA entry status | Signed / Not Signed per project | Signed / Revoked, with unsigned reachable only via the catalog preview | +| Sanctions detection | `isSanctioned` plus string matching on error messages — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it | Typed `code: "company_sanctioned"` on writes; stored `sanctioned` flag on reads | + +## Not yet implemented + +Everything above is the intended model. These parts are not backed end-to-end yet — some +have no backend at all, others store the data but do not expose it. + +| Gap | Effect in the Org lens | Backend status | +|---|---|---| +| **Unsigned acknowledgments are not filtered server-side** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. The CCLA list query filters on signed+approved; the employee signature query filters only on company and project. | needs filing — either filter in the query or confirm the frontend owns it | +| **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | +| **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | +| **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`. | needs filing | +| **GitLab group removals do not invalidate** | A contributor removed from an approved GitLab group keeps an **Authorized** row. Carried over from M2 unchanged. | needs filing | +| **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | +| **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the catalog preview can show it, and it must run its own sanctions check. | open — depends on the preview's data source | +| **Invalidating existing ECLAs on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | +| **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card. They deliberately do not participate in the status model above. | done — out of scope for this matrix | + +## Related tickets + +- [lfx-self-serve#2149](https://github.com/linuxfoundation/lfx-self-serve/issues/2149) — org CLA groups list (`signed`, `signedOn`, `sanctioned`, counts) +- [lfx-self-serve#2231](https://github.com/linuxfoundation/lfx-self-serve/issues/2231) — `signedBy` on the list entry +- [lfx-self-serve#2222](https://github.com/linuxfoundation/lfx-self-serve/issues/2222) — `approvalCriteriaCount` +- [lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) — self-serve corporate signature (the *Sign CLA* path) +- [lfx-self-serve#2151](https://github.com/linuxfoundation/lfx-self-serve/issues/2151) — CLA manager requests + ECLA invalidate (`reason` enum) +- [lfx-self-serve#2153](https://github.com/linuxfoundation/lfx-self-serve/issues/2153) — sanctioned-company write gating +- [lfx-self-serve#2186](https://github.com/linuxfoundation/lfx-self-serve/issues/2186) — email removal invalidates all matching acknowledgments +- [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) — invalidating existing ECLAs when a company becomes sanctioned diff --git a/docs/MY_CLAS_STATUS_MATRIX.md b/docs/MY_CLAS_STATUS_MATRIX.md index 89d07fa80..f475f9543 100644 --- a/docs/MY_CLAS_STATUS_MATRIX.md +++ b/docs/MY_CLAS_STATUS_MATRIX.md @@ -2,6 +2,7 @@ **Parent spec**: FR-010 in [specs/001-easycla-ss-integration-fable/spec.md](../specs/001-easycla-ss-integration-fable/spec.md); milestone brief [02-milestone-sign-cla-fable.md](../specs/001-easycla-ss-integration-fable/02-milestone-sign-cla-fable.md), merged to `dev` with [easycla#5144](https://github.com/linuxfoundation/easycla/pull/5144) **Last verified against shipped code**: 2026-09-03 +**Companion**: [M3_ORG_LENS_STATUS_MATRIX.md](M3_ORG_LENS_STATUS_MATRIX.md) — the target status model for the **Org** lens, including a [cross-lens naming map](M3_ORG_LENS_STATUS_MATRIX.md#cross-lens-naming-map) for the statuses named differently there Single source of truth for the status a My CLAs row shows. The status model is **shipped and settled** — the backend status fields (`easycla` `dev`, including [easycla#5156](https://github.com/linuxfoundation/easycla/pull/5156)) and the frontend rendering ([lfx-self-serve#1440](https://github.com/linuxfoundation/lfx-self-serve/pull/1440), merged 2026-08-21). [Not yet implemented](#not-yet-implemented) lists everything a contributor cannot do or see today, so nobody builds to it by mistake. From 5d358ee414e2838ca0061b9d4c9a2242cb7b81b9 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 13:57:46 -0700 Subject: [PATCH 02/11] docs: record CCLA signing sanctions gate in M3 status matrix Signing a CCLA is already blocked for a sanctioned signing entity, which the matrix left implicit. Both gates in v2/sign/service.go go through checkCompanyCompliance and re-screen against SSS rather than trusting the stored flag: once before the DocuSign envelope is created, and again in the completion callback before signature_signed is set. The M3 self-serve endpoint delegates to the same method and inherits both. Consequences recorded: a Revoked entry can never become Signed, and Sign CLA must be unavailable from a Not started preview for a sanctioned entity. Also define "Sign CLA" on first use rather than assuming the term reads, and add a gap: the signing gates return a plain error, not the typed 403 company_sanctioned body the gated write ops return, so the refusal is not machine-readable. Signed-off-by: Michal Lehotsky Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 36 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index e0af2edd0..6ab4370df 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -38,14 +38,26 @@ Rules that hold across the table: no entry at all. This mirrors M2's rule that unsigned agreements are never shown. A consequence: `signed` is always `true` on a returned entry, so it is a sanity check rather than a discriminator. -- **Not started exists only as a preview.** It is reachable solely through the *Sign CLA* - catalog search: picking a CLA group the company has not signed opens a transient detail - page whose managers and approval tabs are locked. It is never a row in the org's CLA - list, and a reload without the search loses it. Treating it as a persisted status would - imply the API can return it. +- **Not started exists only as a preview.** It is reachable solely through **Sign CLA** — + the search in the Org lens that lets an admin look up any CLA group in the LF project + catalog, including ones their organization has no agreement with, in order to start + signing one. Picking an unsigned CLA group there opens a transient detail page whose + managers and approval tabs are locked. It is never a row in the org's CLA list, and a + reload without the search loses it. Treating it as a persisted status would imply the + API can return it. - **Revoked wins over Signed.** A sanctioned signing entity with a signed agreement shows **Revoked**. It is the more consequential fact and the more restrictive status — the entry becomes read-only — so it takes precedence, exactly as in M2. +- **Signing a CCLA is blocked while the signing entity is sanctioned**, so a **Revoked** + entry can never become **Signed** and *Sign CLA* must be unavailable from a **Not + started** preview for a sanctioned entity. The backend enforces this at two points in + `v2/sign/service.go`, both via `checkCompanyCompliance`, which re-screens against the + Sanctions Screening Service rather than trusting the stored flag: once **before** the + DocuSign envelope is created, and again in the **completion callback** before + `signature_signed` is set — so a company that becomes blocked mid-signing does not get + a finalized CCLA. Manual/admin blocks short-circuit without an SSS call; SSS-origin + blocks fall through so a now-clean result can clear them. The M3 self-serve endpoint + delegates to this same method and inherits both gates. - **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" remain banned copy**. Both rules carry over from M2 unchanged. - **A date is shown only when a real one was recorded.** `signedBy` is omitted when the @@ -61,14 +73,15 @@ Read top to bottom; the first matching row wins. | No signed+approved CCLA | not returned by the list endpoint | *entry does not exist* | | Signing entity is sanctioned | company `is_sanctioned = true` → `sanctioned = true` | **Revoked** | | A corporate agreement is in force | `signed = true` | **Signed** | -| Reached only via the *Sign CLA* catalog | — | **Not started** (preview only) | +| Reached only via **Sign CLA** search | — | **Not started** (preview only) | A consequence worth stating plainly: a signing entity that is **sanctioned and has never signed** cannot appear anywhere in the org's CLA list, because the list is built from signatures. The prototype shows exactly this case (a sanctioned CLA group with no -agreement), and it is only reachable through the catalog preview. The catalog preview -must therefore apply the sanctions check itself rather than inheriting it from a list -entry that does not exist. +agreement), and it is only reachable through the **Sign CLA** preview. That preview must +therefore apply the sanctions check itself rather than inheriting it from a list entry +that does not exist — and because signing is gated server-side regardless, an admin who +gets that far is refused at the API rather than silently creating an envelope. ## Acknowledgment statuses @@ -165,8 +178,8 @@ Two divergences are load-bearing: |---|---|---| | Acknowledgment statuses | Authorized / Not Authorized / Not set up, from two booleans | Authorized / Not Authorized / Invalidated; unsigned rows dropped rather than labelled | | Voided vs not-covered | Indistinguishable — both read "Not Authorized" | Split, with different colors, copy and recoverability | -| CLA entry status | Signed / Not Signed per project | Signed / Revoked, with unsigned reachable only via the catalog preview | -| Sanctions detection | `isSanctioned` plus string matching on error messages — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it | Typed `code: "company_sanctioned"` on writes; stored `sanctioned` flag on reads | +| CLA entry status | Signed / Not Signed per project | Signed / Revoked, with unsigned reachable only via the **Sign CLA** preview | +| Sanctions detection | `isSanctioned` plus string matching on error messages — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it | Typed `code: "company_sanctioned"` on the gated write ops; stored `sanctioned` flag on reads; live SSS re-screening on the CCLA signing path | ## Not yet implemented @@ -178,6 +191,7 @@ have no backend at all, others store the data but do not expose it. | **Unsigned acknowledgments are not filtered server-side** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. The CCLA list query filters on signed+approved; the employee signature query filters only on company and project. | needs filing — either filter in the query or confirm the frontend owns it | | **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | | **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | +| **The signing refusal is not machine-readable** | The two CCLA signing gates return a plain error (`company requires further review for trade compliance`), not the typed `403 company_sanctioned` body the gated write ops return. The Org lens would have to string-match to tell a sanctions refusal from any other signing failure — the same fragility as today's console `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path too | | **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`. | needs filing | | **GitLab group removals do not invalidate** | A contributor removed from an approved GitLab group keeps an **Authorized** row. Carried over from M2 unchanged. | needs filing | | **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | From 70dd24aa36eee9586856eafbc10ac129ee1ad758 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 14:03:49 -0700 Subject: [PATCH 03/11] docs: correct the invalidation analysis in the M3 status matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Why Not Authorized is nearly unreachable today" section overstated how uniform approval-list invalidation is. Corrected against verifyUserApprovals: - Not every criterion is guarded by userStillApproved. Only the email, GitHub-username, GitLab-username and email-domain branches use it; the GitHub-org branch has its own narrower check. - GitLab group removals invalidate nothing, but not because the criterion is unhandled upstream: the path does call invalidateSignatures. It is a double no-op — the acknowledgments to iterate are never populated, and verifyUserApprovals has no GitlabOrgCriteria branch. - "Records the sweep skipped" is specific, not vague: a missing user record returns early by design, and each record runs in a goroutine with a recover() that skips on panic. Adds a per-criterion table, and a new gap: the GitHub-org branch over-invalidates by checking only one email with exact case-sensitive matching instead of the full folded re-check, so contributors still covered by a domain rule, GitLab username or secondary email are invalidated anyway. Signed-off-by: Michal Lehotsky Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 46 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 6ab4370df..8a40696ca 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -124,21 +124,40 @@ Rules: This is the central gap in the M3 status model, and it is worth being precise about. -Removing an approval criterion **invalidates matching acknowledgments immediately**. -`invalidateSignatures` in `cla-backend-go/signatures/repository.go` sets -`signature_approved = false` with invalidation metadata for removals of email, email -domain, GitHub username, GitHub org, and GitLab username criteria — each guarded by a -`userStillApproved` veto so a contributor covered by another criterion is left alone. The -row therefore lands in **Invalidated**, not in a lingering recoverable state. +Removing an approval criterion **invalidates matching acknowledgments immediately**, for +most criteria. `invalidateSignatures` in `cla-backend-go/signatures/repository.go` walks +the CCLA's acknowledgments and delegates each one to `verifyUserApprovals`, which sets +`signature_approved = false` with invalidation metadata (`InvalidatedBy`, and +`Reason: "approved list removal ()"`). The row therefore lands in +**Invalidated**, not in a lingering recoverable state. + +`verifyUserApprovals` branches on which criterion was removed, and the branches are **not +uniform** — which matters, because the gaps live in the differences: + +| Removed criterion | Behavior | Veto protecting a still-covered contributor | +|---|---|---| +| Email, GitHub username, GitLab username | Invalidates | `userStillApproved` — full re-check across emails, domain patterns, and both username lists | +| Email domain | Invalidates, but only if the user's emails match a *removed* domain pattern | `userStillApproved` | +| GitHub org | Invalidates if the user's GitHub username is in the removed org's member list | **narrower** — only checks the email and GitHub-username approval lists; a contributor covered solely by a domain rule or a GitLab username is invalidated anyway | +| **GitLab group** | **Invalidates nothing** — see below | n/a | + +`userStillApproved` deliberately does **not** re-check GitHub or GitLab org membership, so +a contributor covered only by *another* org rule is invalidated when one org is removed. So a genuine **Not Authorized** can only arise from: -- **GitLab group removals**, which are not handled at all (the same gap M2 records) — - group membership cannot be checked without per-group tokens. -- **Membership drift** — a contributor leaving an approved GitHub org or GitLab group, - which changes coverage without any approval-list edit and is never re-evaluated. -- **Records the invalidation sweep skipped**, e.g. where the underlying user record is - gone. +- **GitLab group removals**, which invalidate nothing at all. The path does call + `invalidateSignatures`, but it is a double no-op: it never populates the `ECLAs` it + would iterate, and `verifyUserApprovals` has no branch for `GitlabOrgCriteria` — the + sixth criterion falls through every branch and returns `invalidated = false`. A + contributor removed from an approved GitLab group therefore keeps a fully + **Authorized**-looking row. (M2 records the same gap from the contributor's side.) +- **Membership drift** — a contributor leaving an approved GitHub org or GitLab group. + Coverage changes with no approval-list edit at all, so nothing triggers a re-check. +- **Records the sweep skipped.** `verifyUserApprovals` returns early without invalidating + when the user record is missing (`GetUser` returns no record — deliberate, since + invalidating what cannot be re-checked would be destructive), and each acknowledgment is + processed in a goroutine with a `recover()` that logs and skips on panic. The prototype's recovery hint — *add the user to the Approval list, or Invalidate to remove for good* — is only truthful for those cases. For the common case (a manager @@ -193,7 +212,8 @@ have no backend at all, others store the data but do not expose it. | **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | | **The signing refusal is not machine-readable** | The two CCLA signing gates return a plain error (`company requires further review for trade compliance`), not the typed `403 company_sanctioned` body the gated write ops return. The Org lens would have to string-match to tell a sanctions refusal from any other signing failure — the same fragility as today's console `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path too | | **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`. | needs filing | -| **GitLab group removals do not invalidate** | A contributor removed from an approved GitLab group keeps an **Authorized** row. Carried over from M2 unchanged. | needs filing | +| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row. The removal path calls `invalidateSignatures` but never populates the acknowledgments to iterate, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch, so it is a silent no-op rather than a partial one. Carried over from M2 unchanged. | needs filing | +| **GitHub-org removal over-invalidates** | The `GitHubOrgCriteria` branch checks only the email and GitHub-username approval lists before invalidating, instead of the full `userStillApproved` re-check its sibling branches use — and it compares with exact, case-sensitive `StringInSlice` against a single `getBestEmail(user)`, where `userStillApproved` folds case across *all* the user's emails. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry, or a secondary email is invalidated anyway, landing in **Invalidated** while genuinely still approved. | needs filing — a backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | | **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the catalog preview can show it, and it must run its own sanctions check. | open — depends on the preview's data source | | **Invalidating existing ECLAs on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | From 7ab864060ba7708ed05661085deb89aa0bcd71fd Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 14:28:54 -0700 Subject: [PATCH 04/11] docs: correct M3 status matrix against backend and Self Serve Reviewed the matrix against cla-backend-go and the flag-gated Org-lens build in lfx-self-serve. Corrections: - Reframe the header: this document is the target model that implementation is compared against, not a report on what ships. - Record the shared-ApprovalList leak in UpdateApprovalList. Only the domain block assigns ECLAs on the shared struct and neither org block clears it, so an org removal sweeps nothing alone but sweeps the domain-derived set when combined with a domain removal in one request. This is also the only path that reaches the GitHubOrgCriteria branch, so the earlier "double no-op" reading of the GitLab path was wrong in mechanism. Added as a backend-bug gap. - InvalidationMetadata is {InvalidatedBy, Reason, Note}, not two fields; the invalidate endpoint also takes a free-text note. - Soften the CCLA signing gate description: checkCompanyCompliance prefers a live SSS result but falls back to the stored flag in four cases (non-SSS origin, request cache, unconfigured client in optional mode, unreachable SSS). - Name GET /cla-group/search as the Sign CLA preview's data source, which answers the previously-open question about it, and note that it carries no sanctions field and is undocumented in M3_ORG_LENS_API.md. - Console sanctions detection matches the substring "sanctioned", not the trade-compliance prose, and shows two titles (Unable to Sign, Unable to Prepare CCLA). - Add gaps for the two divergences in the shipped Org-lens build: it labels the sanctions state "Sanctioned"/"Unavailable" rather than Revoked, and derives the entry status from two booleans in the BFF where the Me lens consumes an authoritative status. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 103 ++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 8a40696ca..00d218f89 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -6,7 +6,11 @@ SPDX-License-Identifier: CC-BY-4.0 **Parent spec**: milestone brief [03-milestone-ccla-org-lens-fable.md](../specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md); backend endpoints [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md) **Companion**: [MY_CLAS_STATUS_MATRIX.md](MY_CLAS_STATUS_MATRIX.md) — the shipped M2 status model for the **Me** lens -**Status of this document**: **target model, not shipped.** Written against the M3 UI prototype and the M3 backend as it stands on `dev` (2026-09-14). +**Status of this document**: **the target model.** It describes the statuses the Org lens +should show, not what any build currently renders. Written against the M3 UI prototype and +the M3 backend as it stands on `dev` (2026-09-14). Implementation is compared against this +document, not the other way round — where the two differ, +[Not yet implemented](#not-yet-implemented) records it. Single source of truth for the statuses the **Org lens** EasyCLA module shows once the Corporate CLA Console moves into LFX Self Serve. Two surfaces carry a status: the **CLA @@ -41,23 +45,30 @@ Rules that hold across the table: - **Not started exists only as a preview.** It is reachable solely through **Sign CLA** — the search in the Org lens that lets an admin look up any CLA group in the LF project catalog, including ones their organization has no agreement with, in order to start - signing one. Picking an unsigned CLA group there opens a transient detail page whose + signing one. That search is backed by `GET /cla-group/search` (`searchClaGroups`), which + is unscoped by organization — it matches on CLA group, project and foundation names and + on linked GitHub org / GitLab group / Gerrit names, and carries no signature or sanctions + fields at all. Picking an unsigned CLA group there opens a transient detail page whose managers and approval tabs are locked. It is never a row in the org's CLA list, and a reload without the search loses it. Treating it as a persisted status would imply the - API can return it. + list API can return it. - **Revoked wins over Signed.** A sanctioned signing entity with a signed agreement shows **Revoked**. It is the more consequential fact and the more restrictive status — the entry becomes read-only — so it takes precedence, exactly as in M2. - **Signing a CCLA is blocked while the signing entity is sanctioned**, so a **Revoked** entry can never become **Signed** and *Sign CLA* must be unavailable from a **Not started** preview for a sanctioned entity. The backend enforces this at two points in - `v2/sign/service.go`, both via `checkCompanyCompliance`, which re-screens against the - Sanctions Screening Service rather than trusting the stored flag: once **before** the - DocuSign envelope is created, and again in the **completion callback** before - `signature_signed` is set — so a company that becomes blocked mid-signing does not get - a finalized CCLA. Manual/admin blocks short-circuit without an SSS call; SSS-origin - blocks fall through so a now-clean result can clear them. The M3 self-serve endpoint - delegates to this same method and inherits both gates. + `v2/sign/service.go`, both via `checkCompanyCompliance`: once **before** the DocuSign + envelope is created, and again in the **completion callback** before `signature_signed` + is set — so a company that becomes blocked mid-signing does not get a finalized CCLA. + `checkCompanyCompliance` prefers a live Sanctions Screening Service result over the + stored flag, but falls back to the stored flag in four cases: a manual/admin block + (`sanction_origin != "sss"`) short-circuits without an SSS call, a cached result inside + the request is reused, an unconfigured SSS client in optional mode returns + `is_sanctioned`, and an unreachable SSS does the same. SSS-origin blocks do fall through + to a live call, so a now-clean result can clear them; `cla-sss-enabled=false` disables + screening outright. The M3 self-serve endpoint delegates to this same method and + inherits both gates. - **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" remain banned copy**. Both rules carry over from M2 unchanged. - **A date is shown only when a real one was recorded.** `signedBy` is omitted when the @@ -102,12 +113,14 @@ Rules: signature query filters only on company and project, so unsigned records *are* returned today and the frontend must drop them. See [Not yet implemented](#not-yet-implemented). -- **Not Authorized is amber and recoverable; Invalidated is red and deliberate.** The - distinction is the whole point of splitting them: one is a coverage drift the company - can undo, the other is a voided agreement it cannot. Copy must not blur them. +- **Not Authorized is recoverable; Invalidated is not.** The distinction is the whole point + of splitting them: one is a coverage drift the company can undo by re-adding the + contributor, the other is a voided agreement it cannot. Copy and severity must not blur + them — the prototype renders the first amber and the second red. - **Invalidated does not name who did it** unless the record actually says so. New - invalidations store `InvalidationMetadata` (`InvalidatedBy`, `Reason`) and the M3 - invalidate endpoint takes a `reason` enum, but older records carry nothing — so + invalidations store `InvalidationMetadata` (`InvalidatedBy`, `Reason`, `Note`) and the M3 + invalidate endpoint takes a `reason` enum plus a free-text `note`, but older records carry + nothing — so attribution is per-record, never assumed. Same constraint as M2, with a narrower blast radius. @@ -127,9 +140,9 @@ This is the central gap in the M3 status model, and it is worth being precise ab Removing an approval criterion **invalidates matching acknowledgments immediately**, for most criteria. `invalidateSignatures` in `cla-backend-go/signatures/repository.go` walks the CCLA's acknowledgments and delegates each one to `verifyUserApprovals`, which sets -`signature_approved = false` with invalidation metadata (`InvalidatedBy`, and -`Reason: "approved list removal ()"`). The row therefore lands in -**Invalidated**, not in a lingering recoverable state. +`signature_approved = false` with invalidation metadata (`InvalidatedBy` and +`Reason: "approved list removal ()"`; `Note` is left empty on this path). The row +therefore lands in **Invalidated**, not in a lingering recoverable state. `verifyUserApprovals` branches on which criterion was removed, and the branches are **not uniform** — which matters, because the gaps live in the differences: @@ -138,20 +151,35 @@ uniform** — which matters, because the gaps live in the differences: |---|---|---| | Email, GitHub username, GitLab username | Invalidates | `userStillApproved` — full re-check across emails, domain patterns, and both username lists | | Email domain | Invalidates, but only if the user's emails match a *removed* domain pattern | `userStillApproved` | -| GitHub org | Invalidates if the user's GitHub username is in the removed org's member list | **narrower** — only checks the email and GitHub-username approval lists; a contributor covered solely by a domain rule or a GitLab username is invalidated anyway | -| **GitLab group** | **Invalidates nothing** — see below | n/a | +| GitHub org | Invalidates if the user's GitHub username is in the removed org's member list — but only reached at all via the shared-struct leak below | **narrower** — only checks the email and GitHub-username approval lists; a contributor covered solely by a domain rule or a GitLab username is invalidated anyway | +| **GitLab group** | **Invalidates nothing on its own** — see below | n/a | `userStillApproved` deliberately does **not** re-check GitHub or GitLab org membership, so a contributor covered only by *another* org rule is invalidated when one org is removed. +Both **org** branches depend on a quirk worth naming, because it decides whether they run +at all. `UpdateApprovalList` declares **one** `ApprovalList` struct and mutates it in place +as it walks the removal blocks in order (email → domain → GitHub username → GitHub org → +GitLab username → GitLab org). The three username/email blocks each build their own +per-entry copy and reset `ECLAs` to `nil` first, so they stay isolated. The **domain** block +does not: it assigns `ECLAs` on the shared struct, and neither org block clears it. So the +two org blocks iterate whatever the domain block left behind — meaning an org removal +invalidates nothing when it arrives alone, but when a single request removes **both** a +domain entry and an org entry, the org block runs the sweep over the *domain*-derived +acknowledgment set while `Criteria` has been overwritten to the org criterion. This is the +only path by which the GitHub-org branch executes at all, and the set it judges is not the +one that criterion selected. + So a genuine **Not Authorized** can only arise from: -- **GitLab group removals**, which invalidate nothing at all. The path does call - `invalidateSignatures`, but it is a double no-op: it never populates the `ECLAs` it - would iterate, and `verifyUserApprovals` has no branch for `GitlabOrgCriteria` — the - sixth criterion falls through every branch and returns `invalidated = false`. A - contributor removed from an approved GitLab group therefore keeps a fully - **Authorized**-looking row. (M2 records the same gap from the contributor's side.) +- **GitLab group removals**, which invalidate nothing on their own. The path does call + `invalidateSignatures`, but nothing reaches `verifyUserApprovals` with a verdict: the + GitLab-org block never populates the `ECLAs` to iterate, and `verifyUserApprovals` has no + branch for `GitlabOrgCriteria` — the sixth criterion falls through all three branches and + returns `invalidated = false`. So even when acknowledgments *are* iterated, via the + shared-struct leak above, a GitLab-group removal still invalidates none of them, and a + contributor removed from an approved GitLab group keeps a fully **Authorized**-looking + row. (M2 records the same gap from the contributor's side.) - **Membership drift** — a contributor leaving an approved GitHub org or GitLab group. Coverage changes with no approval-list edit at all, so nothing triggers a re-check. - **Records the sweep skipped.** `verifyUserApprovals` returns early without invalidating @@ -178,7 +206,7 @@ person covered" — but support needs to translate between them. | Acknowledged, not covered by criteria | **Needs attention** | Not Authorized | **Not Authorized** | | Acknowledgment voided | **Invalidated** | Not Authorized *(collapsed)* | **Invalidated** | | Coverage could not be determined | **—** (dash) | *never determined — no coverage check runs* | *no equivalent — see gaps* | -| Company under sanctions | **Revoked** (on the ECLA row) | "Unable to Sign" error state | **Revoked** (on the CLA entry) | +| Company under sanctions | **Revoked** (on the ECLA row) | "Unable to Sign" / "Unable to Prepare CCLA" error states | **Revoked** (on the CLA entry) | | Not acknowledged / not signed | *row hidden* | Not set up | *row hidden* | Two divergences are load-bearing: @@ -198,24 +226,35 @@ Two divergences are load-bearing: | Acknowledgment statuses | Authorized / Not Authorized / Not set up, from two booleans | Authorized / Not Authorized / Invalidated; unsigned rows dropped rather than labelled | | Voided vs not-covered | Indistinguishable — both read "Not Authorized" | Split, with different colors, copy and recoverability | | CLA entry status | Signed / Not Signed per project | Signed / Revoked, with unsigned reachable only via the **Sign CLA** preview | -| Sanctions detection | `isSanctioned` plus string matching on error messages — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it | Typed `code: "company_sanctioned"` on the gated write ops; stored `sanctioned` flag on reads; live SSS re-screening on the CCLA signing path | +| Sanctions detection | `isSanctioned` plus a substring test for `"sanctioned"` on the error message text — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it with a machine-readable code | Typed `code: "company_sanctioned"` on the gated write ops; stored `sanctioned` flag on reads; live SSS re-screening on the CCLA signing path | +| Sanctions copy | Two different titles: *Unable to Sign* on the project CLA page, *Unable to Prepare CCLA* in the CCLA dialog, sharing one body string | one state, one wording — **Revoked** on the entry | ## Not yet implemented Everything above is the intended model. These parts are not backed end-to-end yet — some have no backend at all, others store the data but do not expose it. +A first Org-lens EasyCLA build already exists in `lfx-self-serve` behind the +`org-lens-cla-m3-enabled` feature flag (list, card, detail page, approval list, Sign CLA +flow). It is not user-visible, and it diverges from this document in two places — the first +two rows below. Acknowledgment statuses have no UI there at all, so everything in +[Acknowledgment statuses](#acknowledgment-statuses) is still unbuilt. + | Gap | Effect in the Org lens | Backend status | |---|---|---| +| **The shipped build labels the sanctions state "Sanctioned", not "Revoked"** | The card pill reads *Sanctioned* and the detail heading reads *Unavailable*, where this matrix and the M2 Me lens both name the same company-level fact **Revoked**. One state, three words across two lenses. | frontend copy only — rename to **Revoked** to match the Me lens | +| **The shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed into one status in the Self Serve server layer, so the Org lens repeats the two-boolean derivation this matrix criticizes in the old console — just relocated. The Me lens by contrast consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field like the Me lens's | | **Unsigned acknowledgments are not filtered server-side** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. The CCLA list query filters on signed+approved; the employee signature query filters only on company and project. | needs filing — either filter in the query or confirm the frontend owns it | | **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | | **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | | **The signing refusal is not machine-readable** | The two CCLA signing gates return a plain error (`company requires further review for trade compliance`), not the typed `403 company_sanctioned` body the gated write ops return. The Org lens would have to string-match to tell a sanctions refusal from any other signing failure — the same fragility as today's console `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path too | -| **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`. | needs filing | -| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row. The removal path calls `invalidateSignatures` but never populates the acknowledgments to iterate, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch, so it is a silent no-op rather than a partial one. Carried over from M2 unchanged. | needs filing | -| **GitHub-org removal over-invalidates** | The `GitHubOrgCriteria` branch checks only the email and GitHub-username approval lists before invalidating, instead of the full `userStillApproved` re-check its sibling branches use — and it compares with exact, case-sensitive `StringInSlice` against a single `getBestEmail(user)`, where `userStillApproved` folds case across *all* the user's emails. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry, or a secondary email is invalidated anyway, landing in **Invalidated** while genuinely still approved. | needs filing — a backend bug, not a display gap | +| **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`, with no date field at all. | needs filing | +| **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md), so the one status that depends on it has no documented contract. | needs filing — document the endpoint | +| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row. `verifyUserApprovals` has no `GitlabOrgCriteria` branch, so the criterion falls through and reports nothing invalidated — and the GitLab-org block never populates the acknowledgments to iterate in the first place. Carried over from M2 unchanged. | needs filing | +| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList` in place; only the domain block assigns `ECLAs` on it, and neither org block clears it. An org removal alone sweeps nothing; an org removal **combined with a domain removal in the same request** sweeps the domain-derived set under the org criterion. This is also the only way the GitHub-org branch runs at all. | needs filing — a backend bug; reset `ECLAs` per block or give each block its own struct | +| **GitHub-org removal over-invalidates** | When it does run (see the row above), the `GitHubOrgCriteria` branch checks only the email and GitHub-username approval lists before invalidating, instead of the full `userStillApproved` re-check its sibling branches use — and it compares with exact, case-sensitive `StringInSlice` against a single `getBestEmail(user)`, where `userStillApproved` folds case across *all* the user's emails. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry, or a secondary email is invalidated anyway, landing in **Invalidated** while genuinely still approved. | needs filing — a backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | -| **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the catalog preview can show it, and it must run its own sanctions check. | open — depends on the preview's data source | +| **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the **Sign CLA** preview can show it. That preview is backed by `GET /cla-group/search`, whose result model carries no `sanctioned` field, so the preview must resolve the sanctions state separately rather than reading it from the search result. | needs filing — either add the flag to the search result or have the preview resolve the company | | **Invalidating existing ECLAs on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | | **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card. They deliberately do not participate in the status model above. | done — out of scope for this matrix | From 03397942ee2ba4a767f5e0be0bf2015e411824dc Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 15:01:45 -0700 Subject: [PATCH 05/11] docs(review): address PR #5211 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot[bot], coderabbitai: - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the "a date is shown only when a real one was recorded" rule — the list service substitutes signature_created when the signature carries no SignedOn, so the rule holds for signedBy only (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: qualify "a Revoked entry can never become Signed" — cla-sss-enabled=false returns "not sanctioned" before any stored SSS-origin flag is consulted, so with screening disabled an SSS-blocked company can sign (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: qualify Invalidated as final — autoCreateECLA runs ValidateProjectRecord on approval-list updates and sets signature_approved = true, silently returning a deliberately invalidated row to Authorized (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: resolve the internal contradiction in "Why Not Authorized is nearly unreachable today" — the three cases leave coverage stale rather than producing a Not Authorized verdict (per coderabbitai) - docs/M3_ORG_LENS_STATUS_MATRIX.md: extend the unsigned-acknowledgments gap with the pagination effect — the page query filters on company only while totalCount also filters signed+approved, so unsigned rows consume page slots while being excluded from the total (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: lowercase "approval list" mid-sentence (per coderabbitai) Add two gap rows for the behaviors uncovered while verifying the above: autoCreateECLA resurrecting invalidated acknowledgments, and signedOn possibly carrying a creation date. Resolves 6 review threads. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 51 ++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 00d218f89..571d8cd09 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -66,14 +66,22 @@ Rules that hold across the table: (`sanction_origin != "sss"`) short-circuits without an SSS call, a cached result inside the request is reused, an unconfigured SSS client in optional mode returns `is_sanctioned`, and an unreachable SSS does the same. SSS-origin blocks do fall through - to a live call, so a now-clean result can clear them; `cla-sss-enabled=false` disables - screening outright. The M3 self-serve endpoint delegates to this same method and - inherits both gates. + to a live call, so a now-clean result can clear them. **One operational exception matters + for this rule**: `cla-sss-enabled=false` returns "not sanctioned" *before* any stored + SSS-origin flag is consulted, so with screening disabled a company blocked by SSS can + sign — only manual/admin blocks still refuse, because they short-circuit above that + check. So "a Revoked entry can never become Signed" holds only while screening is + enabled. The M3 self-serve endpoint delegates to this same method and inherits both + gates and this exception. - **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" remain banned copy**. Both rules carry over from M2 unchanged. -- **A date is shown only when a real one was recorded.** `signedBy` is omitted when the +- **A name is shown only when a real one was recorded.** `signedBy` is omitted when the CCLA carries no `SignatoryName`, and there is no CLA-manager fallback — the line then - degrades to *Signed on {date}* rather than naming the wrong person. + degrades to *Signed on {date}* rather than naming the wrong person. **The date does not + yet hold to the same standard**: the list service substitutes `signature_created` when the + signature carries no `SignedOn`, so *Signed on {date}* can present a creation date as a + signing date, and the entry carries no flag distinguishing the two. That breaks M2's "a + wrong date is worse than none" rule — see [Not yet implemented](#not-yet-implemented). ### Which status a CLA entry gets @@ -102,7 +110,7 @@ Shown per contributor row in the acknowledgments (employee CLA) table of a CLA e |---|---|---|---| | **Authorized** | The employee acknowledged the corporate agreement and the company's approval criteria still cover them. | `signatureSigned = true` **and** `signatureApproved = true` | no | | **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked their access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back to the approval list restores them. | **none today** — no coverage verdict is computed for this row, see [Not yet implemented](#not-yet-implemented) | no | -| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. | `signatureApproved = false` | yes — *Invalidated · date* (field not exposed on this row today) | +| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: see the `autoCreateECLA` rule below. | `signatureApproved = false` | yes — *Invalidated · date* (field not exposed on this row today) | Rules: @@ -117,6 +125,13 @@ Rules: of splitting them: one is a coverage drift the company can undo by re-adding the contributor, the other is a voided agreement it cannot. Copy and severity must not blur them — the prototype renders the first amber and the second red. +- **`autoCreateECLA` breaks that rule today.** When the CCLA has auto-create enabled, an + approval-list update calls `CreateOrUpdateEmployeeSignature`, which runs + `ValidateProjectRecord` against every acknowledgment where `signature_approved` or + `signature_signed` is false — and that sets `signature_approved = true`. So an + **Invalidated** row silently returns to **Authorized** on the next approval-list edit, + including one a CLA manager invalidated deliberately. The target model treats Invalidated + as final; see [Not yet implemented](#not-yet-implemented). - **Invalidated does not name who did it** unless the record actually says so. New invalidations store `InvalidationMetadata` (`InvalidatedBy`, `Reason`, `Note`) and the M3 invalidate endpoint takes a `reason` enum plus a free-text `note`, but older records carry @@ -170,7 +185,11 @@ acknowledgment set while `Criteria` has been overwritten to the org criterion. T only path by which the GitHub-org branch executes at all, and the set it judges is not the one that criterion selected. -So a genuine **Not Authorized** can only arise from: +What remains are the cases that leave an acknowledgment's coverage **stale** — the +contributor is no longer covered, but nothing recorded it. These are the conditions +**Not Authorized** is meant to name; today they produce no status change at all, so the row +keeps reading **Authorized**. The status is not merely rare, it is unreachable until a +coverage verdict exists: - **GitLab group removals**, which invalidate nothing on their own. The path does call `invalidateSignatures`, but nothing reaches `verifyUserApprovals` with a verdict: the @@ -187,12 +206,14 @@ So a genuine **Not Authorized** can only arise from: invalidating what cannot be re-checked would be destructive), and each acknowledgment is processed in a goroutine with a `recover()` that logs and skips on panic. -The prototype's recovery hint — *add the user to the Approval list, or Invalidate to -remove for good* — is only truthful for those cases. For the common case (a manager -removes a criterion) re-adding the criterion does **not** restore the row, because the -acknowledgment was already voided. Either the row needs a live coverage verdict from the -backend, or removal must stop auto-invalidating. This is an open product decision, not a -copy fix. +The prototype's recovery hint — *add the user to the approval list, or Invalidate to +remove for good* — is untruthful for the common case. When a manager removes a criterion +the acknowledgment is already voided, so re-adding the criterion does not restore the row; +the contributor must acknowledge again. The hint only describes reality where +`autoCreateECLA` happens to be enabled, and there it works by re-approving invalidated +records indiscriminately rather than by any coverage logic. Either the row needs a live +coverage verdict from the backend, or removal must stop auto-invalidating. This is an open +product decision, not a copy fix. ## Cross-lens naming map @@ -244,7 +265,9 @@ two rows below. Acknowledgment statuses have no UI there at all, so everything i |---|---|---| | **The shipped build labels the sanctions state "Sanctioned", not "Revoked"** | The card pill reads *Sanctioned* and the detail heading reads *Unavailable*, where this matrix and the M2 Me lens both name the same company-level fact **Revoked**. One state, three words across two lenses. | frontend copy only — rename to **Revoked** to match the Me lens | | **The shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed into one status in the Self Serve server layer, so the Org lens repeats the two-boolean derivation this matrix criticizes in the old console — just relocated. The Me lens by contrast consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field like the Me lens's | -| **Unsigned acknowledgments are not filtered server-side** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. The CCLA list query filters on signed+approved; the employee signature query filters only on company and project. | needs filing — either filter in the query or confirm the frontend owns it | +| **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. Worse, the two queries disagree: the page query filters on company only, while `totalCount` also filters `signature_approved` and `signature_signed`. Unsigned and invalidated records therefore consume page slots and cursors while being excluded from the reported total, so client-side dropping yields short pages and a count that does not match the rows. Dropping them in the frontend cannot fix the pagination. | needs filing — filter in **both** queries; a frontend-only fix is not sufficient | +| **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final. It is not: on a CCLA with auto-create enabled, every approval-list update calls `CreateOrUpdateEmployeeSignature`, which runs `ValidateProjectRecord` over each acknowledgment where `signature_approved` or `signature_signed` is false and sets `signature_approved = true`. A row a CLA manager invalidated deliberately silently returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — a likely backend bug; auto-create should not re-approve records that were invalidated | +| **`signedOn` may be a creation date, not a signing date** | *Signed on {date}* is not guaranteed to be a signing date: the list service substitutes `signature_created` when the signature carries no `SignedOn`, and the entry carries no flag distinguishing the two. This breaks the M2 rule that a wrong date is worse than none. | needs filing — omit the field when no signing timestamp exists, or mark it approximate | | **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | | **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | | **The signing refusal is not machine-readable** | The two CCLA signing gates return a plain error (`company requires further review for trade compliance`), not the typed `403 company_sanctioned` body the gated write ops return. The Org lens would have to string-match to tell a sanctions refusal from any other signing failure — the same fragility as today's console `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path too | From b80e47742097d5221fadf702b133d7d87de2303b Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 15:14:30 -0700 Subject: [PATCH 06/11] docs(review): address second-round PR #5211 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments from copilot[bot], coderabbitai: - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the sanctions fallback description — the compliance cache is service-scoped with a five-minute TTL, not per-request, and required SSS mode returns an error rather than honoring the stored flag when no live result is available, so it fails closed (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the org-removal remedy — per-block isolation alone makes a standalone org removal invalidate nothing, because the org blocks never load acknowledgments; the fix must also load the set the removed org selects (per copilot[bot]) - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the pagination remedy — the document maps signed-but-unapproved rows to Invalidated, so "filter in both queries" must not include signature_approved or it would hide them; both queries should select on signature_signed only (per coderabbitai) Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 33 +++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 571d8cd09..fc1676e82 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -62,17 +62,24 @@ Rules that hold across the table: envelope is created, and again in the **completion callback** before `signature_signed` is set — so a company that becomes blocked mid-signing does not get a finalized CCLA. `checkCompanyCompliance` prefers a live Sanctions Screening Service result over the - stored flag, but falls back to the stored flag in four cases: a manual/admin block - (`sanction_origin != "sss"`) short-circuits without an SSS call, a cached result inside - the request is reused, an unconfigured SSS client in optional mode returns - `is_sanctioned`, and an unreachable SSS does the same. SSS-origin blocks do fall through - to a live call, so a now-clean result can clear them. **One operational exception matters - for this rule**: `cla-sss-enabled=false` returns "not sanctioned" *before* any stored - SSS-origin flag is consulted, so with screening disabled a company blocked by SSS can - sign — only manual/admin blocks still refuse, because they short-circuit above that - check. So "a Revoked entry can never become Signed" holds only while screening is - enabled. The M3 self-serve endpoint delegates to this same method and inherits both - gates and this exception. + stored flag, and what happens when it cannot get one depends on the **SSS mode**: + - **A manual/admin block** (`sanction_origin != "sss"`) short-circuits first, without + an SSS call, in either mode. SSS-origin blocks fall through to a live call, so a + now-clean result can clear them. + - **A cached decision wins next.** The cache is **service-scoped with a five-minute + TTL**, not per-request, so a clean decision taken for one company can be reused + across requests for up to five minutes and is mirrored onto the loaded model. + - **When no live result is available** — unconfigured client, no external ID, + unresolvable domain, SSS unreachable, or an ambiguous status — the two modes + diverge. In **optional** mode the stored `is_sanctioned` is honored; in **required** + mode the call returns an **error and signing is blocked**, so required mode fails + closed rather than falling back to the flag. + **One operational exception matters for this rule**: `cla-sss-enabled=false` returns + "not sanctioned" *before* the cache and before any stored SSS-origin flag is consulted, + so with screening disabled a company blocked by SSS can sign — only manual/admin blocks + still refuse, because they short-circuit above that check. So "a Revoked entry can never + become Signed" holds only while screening is enabled. The M3 self-serve endpoint + delegates to this same method and inherits both gates and this exception. - **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" remain banned copy**. Both rules carry over from M2 unchanged. - **A name is shown only when a real one was recorded.** `signedBy` is omitted when the @@ -265,7 +272,7 @@ two rows below. Acknowledgment statuses have no UI there at all, so everything i |---|---|---| | **The shipped build labels the sanctions state "Sanctioned", not "Revoked"** | The card pill reads *Sanctioned* and the detail heading reads *Unavailable*, where this matrix and the M2 Me lens both name the same company-level fact **Revoked**. One state, three words across two lenses. | frontend copy only — rename to **Revoked** to match the Me lens | | **The shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed into one status in the Self Serve server layer, so the Org lens repeats the two-boolean derivation this matrix criticizes in the old console — just relocated. The Me lens by contrast consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field like the Me lens's | -| **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. Worse, the two queries disagree: the page query filters on company only, while `totalCount` also filters `signature_approved` and `signature_signed`. Unsigned and invalidated records therefore consume page slots and cursors while being excluded from the reported total, so client-side dropping yields short pages and a count that does not match the rows. Dropping them in the frontend cannot fix the pagination. | needs filing — filter in **both** queries; a frontend-only fix is not sufficient | +| **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. Worse, the two queries disagree: the page query filters on company only, while `totalCount` also filters `signature_approved` and `signature_signed`. Unsigned and invalidated records therefore consume page slots and cursors while being excluded from the reported total, so client-side dropping yields short pages and a count that does not match the rows. Dropping them in the frontend cannot fix the pagination. | needs filing — both queries must select the **same displayed-row set**: require `signature_signed = true` in each, and **keep** signed rows with `signature_approved = false`, since those are exactly the **Invalidated** rows the lens must show. Filtering `signature_approved` in the page query would hide them. A frontend-only fix is not sufficient | | **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final. It is not: on a CCLA with auto-create enabled, every approval-list update calls `CreateOrUpdateEmployeeSignature`, which runs `ValidateProjectRecord` over each acknowledgment where `signature_approved` or `signature_signed` is false and sets `signature_approved = true`. A row a CLA manager invalidated deliberately silently returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — a likely backend bug; auto-create should not re-approve records that were invalidated | | **`signedOn` may be a creation date, not a signing date** | *Signed on {date}* is not guaranteed to be a signing date: the list service substitutes `signature_created` when the signature carries no `SignedOn`, and the entry carries no flag distinguishing the two. This breaks the M2 rule that a wrong date is worse than none. | needs filing — omit the field when no signing timestamp exists, or mark it approximate | | **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | @@ -274,7 +281,7 @@ two rows below. Acknowledgment statuses have no UI there at all, so everything i | **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`, with no date field at all. | needs filing | | **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md), so the one status that depends on it has no documented contract. | needs filing — document the endpoint | | **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row. `verifyUserApprovals` has no `GitlabOrgCriteria` branch, so the criterion falls through and reports nothing invalidated — and the GitLab-org block never populates the acknowledgments to iterate in the first place. Carried over from M2 unchanged. | needs filing | -| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList` in place; only the domain block assigns `ECLAs` on it, and neither org block clears it. An org removal alone sweeps nothing; an org removal **combined with a domain removal in the same request** sweeps the domain-derived set under the org criterion. This is also the only way the GitHub-org branch runs at all. | needs filing — a backend bug; reset `ECLAs` per block or give each block its own struct | +| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList` in place; only the domain block assigns `ECLAs` on it, and neither org block clears it. An org removal alone sweeps nothing; an org removal **combined with a domain removal in the same request** sweeps the domain-derived set under the org criterion. This is also the only way the GitHub-org branch runs at all. | needs filing — a backend bug, and the fix has two halves. Isolating per-block state (reset `ECLAs`, or give each block its own struct) stops the wrong-set sweep, but **on its own it makes a standalone org removal invalidate nothing**: the org blocks populate `ApprovalList` and `GitHubUsernames` from org membership and never load acknowledgments at all. The org blocks must also load the acknowledgment set the removed org selects before calling `invalidateSignatures` | | **GitHub-org removal over-invalidates** | When it does run (see the row above), the `GitHubOrgCriteria` branch checks only the email and GitHub-username approval lists before invalidating, instead of the full `userStillApproved` re-check its sibling branches use — and it compares with exact, case-sensitive `StringInSlice` against a single `getBestEmail(user)`, where `userStillApproved` folds case across *all* the user's emails. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry, or a secondary email is invalidated anyway, landing in **Invalidated** while genuinely still approved. | needs filing — a backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | | **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the **Sign CLA** preview can show it. That preview is backed by `GET /cla-group/search`, whose result model carries no `sanctioned` field, so the preview must resolve the sanctions state separately rather than reading it from the search result. | needs filing — either add the flag to the search result or have the preview resolve the company | From 1ebc804688143f1d20404abc31b45e5b472a490d Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 15:49:33 -0700 Subject: [PATCH 07/11] docs: condense M3 org lens status matrix Trim the document to the status model itself. The status tables, decision tables, sanctions rules, cross-lens naming map, console comparison and all gap rows are unchanged in substance. - Replace the "Why Not Authorized is nearly unreachable today" narrative with a short paragraph naming the three stale-coverage cases; the verifyUserApprovals branch table and the UpdateApprovalList shared-struct walkthrough are dropped, since the two org-removal gap rows already carry the defect and its remedy. - Tighten the preamble and remove restatements of content already in tables. - Compress gap-row prose without dropping any row or any remedy. Net: 199 lines removed, 118 added (~29% fewer words). Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 317 +++++++++++------------------- 1 file changed, 118 insertions(+), 199 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index fc1676e82..d284ef807 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -7,22 +7,14 @@ SPDX-License-Identifier: CC-BY-4.0 **Parent spec**: milestone brief [03-milestone-ccla-org-lens-fable.md](../specs/001-easycla-ss-integration-fable/03-milestone-ccla-org-lens-fable.md); backend endpoints [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md) **Companion**: [MY_CLAS_STATUS_MATRIX.md](MY_CLAS_STATUS_MATRIX.md) — the shipped M2 status model for the **Me** lens **Status of this document**: **the target model.** It describes the statuses the Org lens -should show, not what any build currently renders. Written against the M3 UI prototype and -the M3 backend as it stands on `dev` (2026-09-14). Implementation is compared against this -document, not the other way round — where the two differ, -[Not yet implemented](#not-yet-implemented) records it. +should show, not what any build renders. Written against the M3 UI prototype and the M3 +backend on `dev` (2026-09-14). Implementation is compared against this document, not the +other way round; where they differ, [Not yet implemented](#not-yet-implemented) records it. Single source of truth for the statuses the **Org lens** EasyCLA module shows once the Corporate CLA Console moves into LFX Self Serve. Two surfaces carry a status: the **CLA entry** (one signing entity × CLA group) and each **employee acknowledgment** row beneath -it. Every status below names its **backend basis** — the field or computation it reads — -because several statuses in the prototype have no backend basis yet. -[Not yet implemented](#not-yet-implemented) lists those gaps, so nobody builds to them by -mistake. - -Where this document and the current Corporate CLA Console disagree, the console is the -old behavior and this document is the intent — see -[What changes from today's console](#what-changes-from-todays-console). +it. Every status names its **backend basis** — several have none yet. ## CLA entry statuses @@ -34,65 +26,52 @@ Shown on the CLA group card and on its detail page header. | **Revoked** | The signing entity is under a sanctions block, so the agreement cannot be relied on and every write is refused. Set by the system, never by a person in the product. | `sanctioned = true` on the list entry | no — see [Not yet implemented](#not-yet-implemented) | | **Not started** | No agreement exists for this CLA group yet. | none — **not a listable state**, see below | no | -Rules that hold across the table: +Rules: - **Only signed agreements are listed.** `GET /v4/company/external/{companySFID}/cla-groups` - reads its signatures through a query that filters on `signature_signed = true` **and** - `signature_approved = true`, so an unsigned or invalidated corporate agreement produces - no entry at all. This mirrors M2's rule that unsigned agreements are never shown. A - consequence: `signed` is always `true` on a returned entry, so it is a sanity check - rather than a discriminator. -- **Not started exists only as a preview.** It is reachable solely through **Sign CLA** — - the search in the Org lens that lets an admin look up any CLA group in the LF project - catalog, including ones their organization has no agreement with, in order to start - signing one. That search is backed by `GET /cla-group/search` (`searchClaGroups`), which - is unscoped by organization — it matches on CLA group, project and foundation names and - on linked GitHub org / GitLab group / Gerrit names, and carries no signature or sanctions - fields at all. Picking an unsigned CLA group there opens a transient detail page whose - managers and approval tabs are locked. It is never a row in the org's CLA list, and a - reload without the search loses it. Treating it as a persisted status would imply the - list API can return it. -- **Revoked wins over Signed.** A sanctioned signing entity with a signed agreement shows - **Revoked**. It is the more consequential fact and the more restrictive status — the - entry becomes read-only — so it takes precedence, exactly as in M2. -- **Signing a CCLA is blocked while the signing entity is sanctioned**, so a **Revoked** - entry can never become **Signed** and *Sign CLA* must be unavailable from a **Not - started** preview for a sanctioned entity. The backend enforces this at two points in - `v2/sign/service.go`, both via `checkCompanyCompliance`: once **before** the DocuSign - envelope is created, and again in the **completion callback** before `signature_signed` - is set — so a company that becomes blocked mid-signing does not get a finalized CCLA. - `checkCompanyCompliance` prefers a live Sanctions Screening Service result over the - stored flag, and what happens when it cannot get one depends on the **SSS mode**: - - **A manual/admin block** (`sanction_origin != "sss"`) short-circuits first, without - an SSS call, in either mode. SSS-origin blocks fall through to a live call, so a - now-clean result can clear them. - - **A cached decision wins next.** The cache is **service-scoped with a five-minute - TTL**, not per-request, so a clean decision taken for one company can be reused - across requests for up to five minutes and is mirrored onto the loaded model. - - **When no live result is available** — unconfigured client, no external ID, - unresolvable domain, SSS unreachable, or an ambiguous status — the two modes - diverge. In **optional** mode the stored `is_sanctioned` is honored; in **required** - mode the call returns an **error and signing is blocked**, so required mode fails - closed rather than falling back to the flag. - **One operational exception matters for this rule**: `cla-sss-enabled=false` returns - "not sanctioned" *before* the cache and before any stored SSS-origin flag is consulted, - so with screening disabled a company blocked by SSS can sign — only manual/admin blocks - still refuse, because they short-circuit above that check. So "a Revoked entry can never - become Signed" holds only while screening is enabled. The M3 self-serve endpoint - delegates to this same method and inherits both gates and this exception. -- **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" - remain banned copy**. Both rules carry over from M2 unchanged. -- **A name is shown only when a real one was recorded.** `signedBy` is omitted when the - CCLA carries no `SignatoryName`, and there is no CLA-manager fallback — the line then - degrades to *Signed on {date}* rather than naming the wrong person. **The date does not - yet hold to the same standard**: the list service substitutes `signature_created` when the - signature carries no `SignedOn`, so *Signed on {date}* can present a creation date as a - signing date, and the entry carries no flag distinguishing the two. That breaks M2's "a - wrong date is worse than none" rule — see [Not yet implemented](#not-yet-implemented). + filters on `signature_signed = true` **and** `signature_approved = true`, so an unsigned or + invalidated corporate agreement produces no entry at all — mirroring M2's rule that unsigned + agreements are never shown. Consequently `signed` is always `true` on a returned entry: a + sanity check, not a discriminator. +- **Not started exists only as a preview.** It is reachable solely through **Sign CLA**, the + unscoped catalog search backed by `GET /cla-group/search`, which carries no signature or + sanctions fields. Picking an unsigned CLA group there opens a transient detail page with + managers and approval tabs locked; it is never a row in the org's CLA list and a reload + loses it. Treating it as a persisted status would imply the list API can return it. +- **Revoked wins over Signed**, exactly as in M2 — the more consequential fact and the more + restrictive status, since the entry becomes read-only. +- **Signing a CCLA is blocked while the signing entity is sanctioned**, so a **Revoked** entry + can never become **Signed**, and *Sign CLA* must be unavailable from a **Not started** + preview for a sanctioned entity. `v2/sign/service.go` enforces this at two points via + `checkCompanyCompliance` — before the DocuSign envelope is created, and again in the + completion callback before `signature_signed` is set. That method prefers a live Sanctions + Screening Service result over the stored flag: + - **A manual/admin block** (`sanction_origin != "sss"`) short-circuits first, with no SSS + call. SSS-origin blocks fall through to a live call, so a now-clean result can clear them. + - **A cached decision wins next.** The cache is **service-scoped with a five-minute TTL**, + not per-request, so a decision can be reused across requests for up to five minutes. + - **When no live result is available** — unconfigured client, no external ID, unresolvable + domain, SSS unreachable, or an ambiguous status — the modes diverge: **optional** mode + honors the stored `is_sanctioned`; **required** mode returns an error and blocks signing, + failing closed rather than falling back to the flag. + + **One exception**: `cla-sss-enabled=false` returns "not sanctioned" *before* the cache and + before any stored SSS-origin flag, so with screening disabled a company blocked by SSS can + sign — only manual/admin blocks still refuse. "A Revoked entry can never become Signed" holds + only while screening is enabled. The M3 self-serve endpoint delegates to this same method and + inherits both gates and the exception. +- **Invalidated and Revoked must never share wording**, and **"Canceled" and "Invalid" remain + banned copy**. Both carry over from M2 unchanged. +- **A name is shown only when a real one was recorded.** `signedBy` is omitted when the CCLA + carries no `SignatoryName` — no CLA-manager fallback — so the line degrades to *Signed on + {date}* rather than naming the wrong person. **The date does not yet hold to that standard**: + the list service substitutes `signature_created` when the signature has no `SignedOn`, so + *Signed on {date}* can present a creation date as a signing date, breaking M2's "a wrong date + is worse than none" rule. See [Not yet implemented](#not-yet-implemented). ### Which status a CLA entry gets -Read top to bottom; the first matching row wins. +First matching row wins. | Condition | In the data | Status | |---|---|---| @@ -101,13 +80,11 @@ Read top to bottom; the first matching row wins. | A corporate agreement is in force | `signed = true` | **Signed** | | Reached only via **Sign CLA** search | — | **Not started** (preview only) | -A consequence worth stating plainly: a signing entity that is **sanctioned and has never -signed** cannot appear anywhere in the org's CLA list, because the list is built from -signatures. The prototype shows exactly this case (a sanctioned CLA group with no -agreement), and it is only reachable through the **Sign CLA** preview. That preview must -therefore apply the sanctions check itself rather than inheriting it from a list entry -that does not exist — and because signing is gated server-side regardless, an admin who -gets that far is refused at the API rather than silently creating an envelope. +A signing entity that is **sanctioned and has never signed** cannot appear in the org's CLA +list at all, because the list is built from signatures. The prototype shows exactly that case, +reachable only through the **Sign CLA** preview — so the preview must apply the sanctions check +itself rather than inheriting it from an entry that does not exist. Signing is gated +server-side regardless, so an admin who gets that far is refused at the API. ## Acknowledgment statuses @@ -116,35 +93,30 @@ Shown per contributor row in the acknowledgments (employee CLA) table of a CLA e | Status | What it means | Backend basis | Dated? | |---|---|---|---| | **Authorized** | The employee acknowledged the corporate agreement and the company's approval criteria still cover them. | `signatureSigned = true` **and** `signatureApproved = true` | no | -| **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked their access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back to the approval list restores them. | **none today** — no coverage verdict is computed for this row, see [Not yet implemented](#not-yet-implemented) | no | -| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: see the `autoCreateECLA` rule below. | `signatureApproved = false` | yes — *Invalidated · date* (field not exposed on this row today) | +| **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back restores them. | **none today** — no coverage verdict is computed, see [Not yet implemented](#not-yet-implemented) | no | +| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: `autoCreateECLA`, below. | `signatureApproved = false` | yes — *Invalidated · date* (not exposed on this row today) | Rules: -- **Unsigned acknowledgments should never be shown** — a row should exist only once the - employee has acknowledged, `signatureSigned = true`, aligning the org lens with M2's - filter and retiring today's console state **"Not set up"** (`!approved && !signed`). - **This is a proposal, not current behavior**: unlike the CCLA query, the employee - signature query filters only on company and project, so unsigned records *are* returned - today and the frontend must drop them. See - [Not yet implemented](#not-yet-implemented). -- **Not Authorized is recoverable; Invalidated is not.** The distinction is the whole point - of splitting them: one is a coverage drift the company can undo by re-adding the - contributor, the other is a voided agreement it cannot. Copy and severity must not blur - them — the prototype renders the first amber and the second red. -- **`autoCreateECLA` breaks that rule today.** When the CCLA has auto-create enabled, an - approval-list update calls `CreateOrUpdateEmployeeSignature`, which runs - `ValidateProjectRecord` against every acknowledgment where `signature_approved` or - `signature_signed` is false — and that sets `signature_approved = true`. So an +- **Unsigned acknowledgments should never be shown** — a row should exist only once + `signatureSigned = true`, aligning with M2's filter and retiring today's console state + **"Not set up"** (`!approved && !signed`). **This is a proposal, not current behavior**: + unlike the CCLA query, the employee signature query filters only on company and project, so + unsigned records *are* returned today. See [Not yet implemented](#not-yet-implemented). +- **Not Authorized is recoverable; Invalidated is not.** That distinction is the whole point of + splitting them: one is coverage drift the company can undo, the other a voided agreement it + cannot. Copy and severity must not blur them — the prototype renders the first amber, the + second red. +- **`autoCreateECLA` breaks that rule today.** With auto-create enabled, an approval-list + update calls `CreateOrUpdateEmployeeSignature` → `ValidateProjectRecord`, which sets + `signature_approved = true` on every acknowledgment where either flag is false. So an **Invalidated** row silently returns to **Authorized** on the next approval-list edit, - including one a CLA manager invalidated deliberately. The target model treats Invalidated - as final; see [Not yet implemented](#not-yet-implemented). -- **Invalidated does not name who did it** unless the record actually says so. New - invalidations store `InvalidationMetadata` (`InvalidatedBy`, `Reason`, `Note`) and the M3 - invalidate endpoint takes a `reason` enum plus a free-text `note`, but older records carry - nothing — so - attribution is per-record, never assumed. Same constraint as M2, with a narrower blast - radius. + including one a CLA manager invalidated deliberately. The target model treats Invalidated as + final; see [Not yet implemented](#not-yet-implemented). +- **Invalidated does not name who did it** unless the record says so. New invalidations store + `InvalidationMetadata` (`InvalidatedBy`, `Reason`, `Note`) and the M3 invalidate endpoint + takes a `reason` enum plus a free-text `note`, but older records carry nothing — attribution + is per-record, never assumed. Same constraint as M2, narrower blast radius. ### Which status an acknowledgment row gets @@ -155,78 +127,31 @@ Rules: | Acknowledgment intact, criteria do not cover the contributor | *not computed today* | **Not Authorized** | | Acknowledgment intact and covered | `signatureApproved = true` | **Authorized** | -### Why Not Authorized is nearly unreachable today - -This is the central gap in the M3 status model, and it is worth being precise about. - -Removing an approval criterion **invalidates matching acknowledgments immediately**, for -most criteria. `invalidateSignatures` in `cla-backend-go/signatures/repository.go` walks -the CCLA's acknowledgments and delegates each one to `verifyUserApprovals`, which sets -`signature_approved = false` with invalidation metadata (`InvalidatedBy` and -`Reason: "approved list removal ()"`; `Note` is left empty on this path). The row -therefore lands in **Invalidated**, not in a lingering recoverable state. - -`verifyUserApprovals` branches on which criterion was removed, and the branches are **not -uniform** — which matters, because the gaps live in the differences: - -| Removed criterion | Behavior | Veto protecting a still-covered contributor | -|---|---|---| -| Email, GitHub username, GitLab username | Invalidates | `userStillApproved` — full re-check across emails, domain patterns, and both username lists | -| Email domain | Invalidates, but only if the user's emails match a *removed* domain pattern | `userStillApproved` | -| GitHub org | Invalidates if the user's GitHub username is in the removed org's member list — but only reached at all via the shared-struct leak below | **narrower** — only checks the email and GitHub-username approval lists; a contributor covered solely by a domain rule or a GitLab username is invalidated anyway | -| **GitLab group** | **Invalidates nothing on its own** — see below | n/a | - -`userStillApproved` deliberately does **not** re-check GitHub or GitLab org membership, so -a contributor covered only by *another* org rule is invalidated when one org is removed. - -Both **org** branches depend on a quirk worth naming, because it decides whether they run -at all. `UpdateApprovalList` declares **one** `ApprovalList` struct and mutates it in place -as it walks the removal blocks in order (email → domain → GitHub username → GitHub org → -GitLab username → GitLab org). The three username/email blocks each build their own -per-entry copy and reset `ECLAs` to `nil` first, so they stay isolated. The **domain** block -does not: it assigns `ECLAs` on the shared struct, and neither org block clears it. So the -two org blocks iterate whatever the domain block left behind — meaning an org removal -invalidates nothing when it arrives alone, but when a single request removes **both** a -domain entry and an org entry, the org block runs the sweep over the *domain*-derived -acknowledgment set while `Criteria` has been overwritten to the org criterion. This is the -only path by which the GitHub-org branch executes at all, and the set it judges is not the -one that criterion selected. - -What remains are the cases that leave an acknowledgment's coverage **stale** — the -contributor is no longer covered, but nothing recorded it. These are the conditions -**Not Authorized** is meant to name; today they produce no status change at all, so the row -keeps reading **Authorized**. The status is not merely rare, it is unreachable until a -coverage verdict exists: - -- **GitLab group removals**, which invalidate nothing on their own. The path does call - `invalidateSignatures`, but nothing reaches `verifyUserApprovals` with a verdict: the - GitLab-org block never populates the `ECLAs` to iterate, and `verifyUserApprovals` has no - branch for `GitlabOrgCriteria` — the sixth criterion falls through all three branches and - returns `invalidated = false`. So even when acknowledgments *are* iterated, via the - shared-struct leak above, a GitLab-group removal still invalidates none of them, and a - contributor removed from an approved GitLab group keeps a fully **Authorized**-looking - row. (M2 records the same gap from the contributor's side.) -- **Membership drift** — a contributor leaving an approved GitHub org or GitLab group. - Coverage changes with no approval-list edit at all, so nothing triggers a re-check. -- **Records the sweep skipped.** `verifyUserApprovals` returns early without invalidating - when the user record is missing (`GetUser` returns no record — deliberate, since - invalidating what cannot be re-checked would be destructive), and each acknowledgment is - processed in a goroutine with a `recover()` that logs and skips on panic. - -The prototype's recovery hint — *add the user to the approval list, or Invalidate to -remove for good* — is untruthful for the common case. When a manager removes a criterion -the acknowledgment is already voided, so re-adding the criterion does not restore the row; -the contributor must acknowledge again. The hint only describes reality where -`autoCreateECLA` happens to be enabled, and there it works by re-approving invalidated -records indiscriminately rather than by any coverage logic. Either the row needs a live -coverage verdict from the backend, or removal must stop auto-invalidating. This is an open -product decision, not a copy fix. +### Why Not Authorized is unreachable today + +Removing an approval criterion **invalidates matching acknowledgments immediately** for most +criteria — `invalidateSignatures` sets `signature_approved = false` with invalidation metadata +— so the row lands in **Invalidated** rather than in a recoverable state. What **Not +Authorized** is meant to name is the residue: cases where coverage went stale and nothing +recorded it, so the row keeps reading **Authorized**. Those are GitLab group removals +(which invalidate nothing), membership drift in an approved GitHub org or GitLab group (no +approval-list edit occurs, so nothing triggers a re-check), and records the sweep skipped +(missing user record, or a panic recovered per acknowledgment). The specific backend defects +behind each are listed in [Not yet implemented](#not-yet-implemented). + +Consequently the prototype's recovery hint — *add the user to the approval list, or Invalidate +to remove for good* — is untruthful for the common case: the acknowledgment is already voided, +so re-adding the criterion does not restore the row. The hint only describes reality where +`autoCreateECLA` is enabled, and there it works by re-approving invalidated records +indiscriminately rather than by any coverage logic. Either the row needs a live coverage +verdict from the backend, or removal must stop auto-invalidating. This is an open product +decision, not a copy fix. ## Cross-lens naming map -The same underlying state is named differently in the Me lens and the Org lens. That is -deliberate — a contributor reads "what do I need to do", an org admin reads "is this -person covered" — but support needs to translate between them. +The same underlying state is named differently in each lens — deliberately, since a contributor +reads "what do I need to do" and an org admin reads "is this person covered" — but support +needs to translate. | Underlying state | Me lens (M2, shipped) | Today's Corporate Console | Org lens (M3, target) | |---|---|---|---| @@ -239,13 +164,11 @@ person covered" — but support needs to translate between them. Two divergences are load-bearing: -- **"Needs attention" ≡ "Not Authorized".** Same state, different audience. The Me-lens - wording is an instruction to the contributor; the org-lens wording is a fact about a - person. Neither name is being changed. -- **"Revoked" sits on a different object in each lens.** In the Me lens it labels one - contributor's ECLA; in the Org lens it labels the CLA entry for a whole signing entity. - The sanction is a company-level fact in both cases — only the object carrying the label - differs. +- **"Needs attention" ≡ "Not Authorized".** Same state, different audience — an instruction to + the contributor versus a fact about a person. Neither name is being changed. +- **"Revoked" sits on a different object in each lens** — one contributor's ECLA in the Me lens, + the whole signing entity's CLA entry in the Org lens. The sanction is company-level in both; + only the object carrying the label differs. ## What changes from today's console @@ -254,39 +177,35 @@ Two divergences are load-bearing: | Acknowledgment statuses | Authorized / Not Authorized / Not set up, from two booleans | Authorized / Not Authorized / Invalidated; unsigned rows dropped rather than labelled | | Voided vs not-covered | Indistinguishable — both read "Not Authorized" | Split, with different colors, copy and recoverability | | CLA entry status | Signed / Not Signed per project | Signed / Revoked, with unsigned reachable only via the **Sign CLA** preview | -| Sanctions detection | `isSanctioned` plus a substring test for `"sanctioned"` on the error message text — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it with a machine-readable code | Typed `code: "company_sanctioned"` on the gated write ops; stored `sanctioned` flag on reads; live SSS re-screening on the CCLA signing path | -| Sanctions copy | Two different titles: *Unable to Sign* on the project CLA page, *Unable to Prepare CCLA* in the CCLA dialog, sharing one body string | one state, one wording — **Revoked** on the entry | +| Sanctions detection | `isSanctioned` plus a substring test for `"sanctioned"` on the error text — `project-active-cla.component.ts` and `ccla-dialog.component.ts` both carry a `TODO(#5078)` to replace it | Typed `code: "company_sanctioned"` on gated write ops; stored `sanctioned` flag on reads; live SSS re-screening on the signing path | +| Sanctions copy | Two titles — *Unable to Sign* and *Unable to Prepare CCLA* — sharing one body string | one state, one wording — **Revoked** on the entry | ## Not yet implemented -Everything above is the intended model. These parts are not backed end-to-end yet — some -have no backend at all, others store the data but do not expose it. - -A first Org-lens EasyCLA build already exists in `lfx-self-serve` behind the -`org-lens-cla-m3-enabled` feature flag (list, card, detail page, approval list, Sign CLA -flow). It is not user-visible, and it diverges from this document in two places — the first -two rows below. Acknowledgment statuses have no UI there at all, so everything in -[Acknowledgment statuses](#acknowledgment-statuses) is still unbuilt. +A first Org-lens EasyCLA build exists in `lfx-self-serve` behind the `org-lens-cla-m3-enabled` +feature flag (list, card, detail page, approval list, Sign CLA flow). It is not user-visible +and diverges from this document in the first two rows below. Acknowledgment statuses have no UI +there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuses) is unbuilt. | Gap | Effect in the Org lens | Backend status | |---|---|---| -| **The shipped build labels the sanctions state "Sanctioned", not "Revoked"** | The card pill reads *Sanctioned* and the detail heading reads *Unavailable*, where this matrix and the M2 Me lens both name the same company-level fact **Revoked**. One state, three words across two lenses. | frontend copy only — rename to **Revoked** to match the Me lens | -| **The shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed into one status in the Self Serve server layer, so the Org lens repeats the two-boolean derivation this matrix criticizes in the old console — just relocated. The Me lens by contrast consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field like the Me lens's | -| **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API, so the Org lens must drop them itself or it will show statusless rows. Worse, the two queries disagree: the page query filters on company only, while `totalCount` also filters `signature_approved` and `signature_signed`. Unsigned and invalidated records therefore consume page slots and cursors while being excluded from the reported total, so client-side dropping yields short pages and a count that does not match the rows. Dropping them in the frontend cannot fix the pagination. | needs filing — both queries must select the **same displayed-row set**: require `signature_signed = true` in each, and **keep** signed rows with `signature_approved = false`, since those are exactly the **Invalidated** rows the lens must show. Filtering `signature_approved` in the page query would hide them. A frontend-only fix is not sufficient | -| **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final. It is not: on a CCLA with auto-create enabled, every approval-list update calls `CreateOrUpdateEmployeeSignature`, which runs `ValidateProjectRecord` over each acknowledgment where `signature_approved` or `signature_signed` is false and sets `signature_approved = true`. A row a CLA manager invalidated deliberately silently returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — a likely backend bug; auto-create should not re-approve records that were invalidated | -| **`signedOn` may be a creation date, not a signing date** | *Signed on {date}* is not guaranteed to be a signing date: the list service substitutes `signature_created` when the signature carries no `SignedOn`, and the entry carries no flag distinguishing the two. This breaks the M2 rule that a wrong date is worse than none. | needs filing — omit the field when no signing timestamp exists, or mark it approximate | -| **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered for the case the prototype describes. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; there is no equivalent of the M2 coverage check. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | +| **Shipped build labels the sanctions state "Sanctioned", not "Revoked"** | Card pill reads *Sanctioned*, detail heading reads *Unavailable* — one company-level fact, three words across two lenses. | frontend copy only — rename to **Revoked** | +| **Shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed in the Self Serve server layer, relocating the two-boolean derivation this matrix criticizes. The Me lens consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field | +| **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API. Worse, the page query filters on company only while `totalCount` also filters `signature_approved` and `signature_signed`, so unsigned and invalidated records consume page slots and cursors while being excluded from the total. Client-side dropping yields short pages and a mismatched count. | needs filing — both queries must select the **same** row set: require `signature_signed = true` in each and **keep** signed rows with `signature_approved = false`, which are the **Invalidated** rows the lens must show. A frontend-only fix is insufficient | +| **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final; it is not. A deliberately invalidated row returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — likely backend bug; auto-create should not re-approve invalidated records | +| **`signedOn` may be a creation date** | The list service substitutes `signature_created` when the signature has no `SignedOn`, and nothing flags the difference — breaking M2's "a wrong date is worse than none". | needs filing — omit the field when no signing timestamp exists, or mark it approximate | +| **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; no equivalent of the M2 coverage check exists. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | | **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | -| **The signing refusal is not machine-readable** | The two CCLA signing gates return a plain error (`company requires further review for trade compliance`), not the typed `403 company_sanctioned` body the gated write ops return. The Org lens would have to string-match to tell a sanctions refusal from any other signing failure — the same fragility as today's console `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path too | -| **Revoked has no date** | The CLA entry shows the status alone. The Me lens dates it from `flaggedAt`; the list entry carries only the boolean `sanctioned`, with no date field at all. | needs filing | -| **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md), so the one status that depends on it has no documented contract. | needs filing — document the endpoint | -| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row. `verifyUserApprovals` has no `GitlabOrgCriteria` branch, so the criterion falls through and reports nothing invalidated — and the GitLab-org block never populates the acknowledgments to iterate in the first place. Carried over from M2 unchanged. | needs filing | -| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList` in place; only the domain block assigns `ECLAs` on it, and neither org block clears it. An org removal alone sweeps nothing; an org removal **combined with a domain removal in the same request** sweeps the domain-derived set under the org criterion. This is also the only way the GitHub-org branch runs at all. | needs filing — a backend bug, and the fix has two halves. Isolating per-block state (reset `ECLAs`, or give each block its own struct) stops the wrong-set sweep, but **on its own it makes a standalone org removal invalidate nothing**: the org blocks populate `ApprovalList` and `GitHubUsernames` from org membership and never load acknowledgments at all. The org blocks must also load the acknowledgment set the removed org selects before calling `invalidateSignatures` | -| **GitHub-org removal over-invalidates** | When it does run (see the row above), the `GitHubOrgCriteria` branch checks only the email and GitHub-username approval lists before invalidating, instead of the full `userStillApproved` re-check its sibling branches use — and it compares with exact, case-sensitive `StringInSlice` against a single `getBestEmail(user)`, where `userStillApproved` folds case across *all* the user's emails. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry, or a secondary email is invalidated anyway, landing in **Invalidated** while genuinely still approved. | needs filing — a backend bug, not a display gap | -| **An invalidated CCLA disappears silently** | The CLA entry vanishes from the list with no trace, so an org admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question — whether an invalidated CCLA deserves its own entry status | -| **Sanctioned + never signed is not listable** | The prototype's Revoked-without-agreement case has no list entry; only the **Sign CLA** preview can show it. That preview is backed by `GET /cla-group/search`, whose result model carries no `sanctioned` field, so the preview must resolve the sanctions state separately rather than reading it from the search result. | needs filing — either add the flag to the search result or have the preview resolve the company | +| **The signing refusal is not machine-readable** | Both CCLA signing gates return a plain error, not the typed `403 company_sanctioned` body the write ops return, so the Org lens would have to string-match — the same fragility as `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path | +| **Revoked has no date** | The entry shows the status alone; the list entry carries only the boolean `sanctioned`. The Me lens dates it from `flaggedAt`. | needs filing | +| **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md). | needs filing — document the endpoint | +| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row: `verifyUserApprovals` has no `GitlabOrgCriteria` branch, and the GitLab-org block never populates the acknowledgments to iterate. Carried over from M2. | needs filing | +| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList`; only the domain block assigns `ECLAs` and neither org block clears it. An org removal alone sweeps nothing; combined with a domain removal in the same request it sweeps the domain-derived set under the org criterion — the only way the GitHub-org branch runs at all. | needs filing — backend bug; the fix has two halves. Isolating per-block state stops the wrong-set sweep but **alone makes a standalone org removal invalidate nothing**, since the org blocks never load acknowledgments. They must also load the set the removed org selects before calling `invalidateSignatures` | +| **GitHub-org removal over-invalidates** | When it runs, the `GitHubOrgCriteria` branch checks only the email and GitHub-username lists instead of the full `userStillApproved` re-check, comparing case-sensitively against a single `getBestEmail(user)`. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry or a secondary email is invalidated anyway. | needs filing — backend bug, not a display gap | +| **An invalidated CCLA disappears silently** | The entry vanishes from the list, so an admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question | +| **Sanctioned + never signed is not listable** | Only the **Sign CLA** preview can show it, and `GET /cla-group/search` carries no `sanctioned` field, so the preview must resolve the sanctions state separately. | needs filing — add the flag to the search result, or have the preview resolve the company | | **Invalidating existing ECLAs on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | -| **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card. They deliberately do not participate in the status model above. | done — out of scope for this matrix | +| **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card; they do not participate in the status model. | done — out of scope | ## Related tickets From ec8740eb8baea80a09d51c391d4ea9db29dc8910 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Mon, 14 Sep 2026 16:18:29 -0700 Subject: [PATCH 08/11] docs: adopt "employee acknowledgment" terminology in M3 status matrix Legal asked us to retire "ECLA"/"Employee CLA" in user-facing text: an employee does not sign a separate agreement, they acknowledge the company's corporate agreement, and "Employee CLA" implies otherwise. - Add a Terminology section mapping the two objects (CLA entry / corporate agreement, and employee acknowledgment) to their backend names, and stating that ECLA survives only as an internal abbreviation in code identifiers, URL paths and schema names. - Replace ECLA in prose, table cells and ticket glosses with "acknowledgment". Code identifiers (autoCreateECLA, ApprovalList.ECLAs) are quoted verbatim and left unchanged. - Label both status section headings with the object they describe, which also resolves the CCLA vs acknowledgment ambiguity the doc carried. - Fix the section anchor the Not-yet-implemented preamble links to. - American spelling throughout: acknowledgment, not acknowledgement. Scope is this document only. Code identifiers, URL paths and JSON fields are deliberately untouched - renaming them would be a breaking API change with no user-visible benefit. Remaining docs and the Self Serve UI are tracked in linuxfoundation/lfx-self-serve#1991. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 38 ++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index d284ef807..6c233be56 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -16,7 +16,23 @@ Corporate CLA Console moves into LFX Self Serve. Two surfaces carry a status: th entry** (one signing entity × CLA group) and each **employee acknowledgment** row beneath it. Every status names its **backend basis** — several have none yet. -## CLA entry statuses +### Terminology + +Two objects, and the names this document uses for them: + +| Object | This document | Backend / code | Do not use | +|---|---|---|---| +| A signing entity's agreement with one CLA group | **CLA entry**, **corporate agreement** | CCLA, `signature_signed`, `corporate-signature` | — | +| One employee's coverage under that agreement | **employee acknowledgment**, **acknowledgment** | ECLA, `employee_signature`, `autoCreateECLA` | **"Employee CLA"**, **"ECLA"** in prose | + +An employee does not sign a separate agreement — they **acknowledge** the company's corporate +agreement. "Employee CLA" implies otherwise and is not used in user-facing text: documentation, +emails and UI all say *employee acknowledgment*. **ECLA** survives only as an internal +abbreviation in code identifiers, URL paths and schema names (`autoCreateECLA`, +`/cla-group/{id}/ecla/{sigID}/invalidate`), which this document quotes verbatim where it cites +them. Spelling is American throughout — *acknowledgment*, not *acknowledgement*. + +## CLA entry statuses (corporate agreement) Shown on the CLA group card and on its detail page header. @@ -86,9 +102,9 @@ reachable only through the **Sign CLA** preview — so the preview must apply th itself rather than inheriting it from an entry that does not exist. Signing is gated server-side regardless, so an admin who gets that far is refused at the API. -## Acknowledgment statuses +## Acknowledgment statuses (employee acknowledgment) -Shown per contributor row in the acknowledgments (employee CLA) table of a CLA entry. +Shown per contributor row in the employee acknowledgment table of a CLA entry. | Status | What it means | Backend basis | Dated? | |---|---|---|---| @@ -159,16 +175,16 @@ needs to translate. | Acknowledged, not covered by criteria | **Needs attention** | Not Authorized | **Not Authorized** | | Acknowledgment voided | **Invalidated** | Not Authorized *(collapsed)* | **Invalidated** | | Coverage could not be determined | **—** (dash) | *never determined — no coverage check runs* | *no equivalent — see gaps* | -| Company under sanctions | **Revoked** (on the ECLA row) | "Unable to Sign" / "Unable to Prepare CCLA" error states | **Revoked** (on the CLA entry) | +| Company under sanctions | **Revoked** (on the acknowledgment row) | "Unable to Sign" / "Unable to Prepare CCLA" error states | **Revoked** (on the CLA entry) | | Not acknowledged / not signed | *row hidden* | Not set up | *row hidden* | Two divergences are load-bearing: - **"Needs attention" ≡ "Not Authorized".** Same state, different audience — an instruction to the contributor versus a fact about a person. Neither name is being changed. -- **"Revoked" sits on a different object in each lens** — one contributor's ECLA in the Me lens, - the whole signing entity's CLA entry in the Org lens. The sanction is company-level in both; - only the object carrying the label differs. +- **"Revoked" sits on a different object in each lens** — one contributor's acknowledgment in + the Me lens, the whole signing entity's CLA entry in the Org lens. The sanction is + company-level in both; only the object carrying the label differs. ## What changes from today's console @@ -185,7 +201,7 @@ Two divergences are load-bearing: A first Org-lens EasyCLA build exists in `lfx-self-serve` behind the `org-lens-cla-m3-enabled` feature flag (list, card, detail page, approval list, Sign CLA flow). It is not user-visible and diverges from this document in the first two rows below. Acknowledgment statuses have no UI -there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuses) is unbuilt. +there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuses-employee-acknowledgment) is unbuilt. | Gap | Effect in the Org lens | Backend status | |---|---|---| @@ -204,7 +220,7 @@ there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuse | **GitHub-org removal over-invalidates** | When it runs, the `GitHubOrgCriteria` branch checks only the email and GitHub-username lists instead of the full `userStillApproved` re-check, comparing case-sensitively against a single `getBestEmail(user)`. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry or a secondary email is invalidated anyway. | needs filing — backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The entry vanishes from the list, so an admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question | | **Sanctioned + never signed is not listable** | Only the **Sign CLA** preview can show it, and `GET /cla-group/search` carries no `sanctioned` field, so the preview must resolve the sanctions state separately. | needs filing — add the flag to the search result, or have the preview resolve the company | -| **Invalidating existing ECLAs on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | +| **Invalidating existing acknowledgments on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | | **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card; they do not participate in the status model. | done — out of scope | ## Related tickets @@ -213,7 +229,7 @@ there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuse - [lfx-self-serve#2231](https://github.com/linuxfoundation/lfx-self-serve/issues/2231) — `signedBy` on the list entry - [lfx-self-serve#2222](https://github.com/linuxfoundation/lfx-self-serve/issues/2222) — `approvalCriteriaCount` - [lfx-self-serve#2150](https://github.com/linuxfoundation/lfx-self-serve/issues/2150) — self-serve corporate signature (the *Sign CLA* path) -- [lfx-self-serve#2151](https://github.com/linuxfoundation/lfx-self-serve/issues/2151) — CLA manager requests + ECLA invalidate (`reason` enum) +- [lfx-self-serve#2151](https://github.com/linuxfoundation/lfx-self-serve/issues/2151) — CLA manager requests + acknowledgment invalidate (`reason` enum) - [lfx-self-serve#2153](https://github.com/linuxfoundation/lfx-self-serve/issues/2153) — sanctioned-company write gating - [lfx-self-serve#2186](https://github.com/linuxfoundation/lfx-self-serve/issues/2186) — email removal invalidates all matching acknowledgments -- [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) — invalidating existing ECLAs when a company becomes sanctioned +- [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) — invalidating existing acknowledgments when a company becomes sanctioned From 33992a951074f9651f782db490a00cc9b74f4a60 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 12:02:12 -0700 Subject: [PATCH 09/11] docs(review): correct M3 matrix against the latest mocks and Self Serve Four corrections from reviewing the document against the M2 matrix, the current M3/M2 prototypes and the shipped Self Serve build. Invalidation metadata is exposed, not missing. corporate-contributor carries invalidatedAt, invalidatedBy, invalidationReason, invalidationNote and note, all populated by GetClaGroupCorporateContributors. The earlier claim came from the gitignored, stale cla-backend-go/gen/ copy rather than the swagger source. Both mocks show the date alone, so the doc now specifies date-only rendering and records the prototype's full-timestamp format as the divergence to fix. GitLab group criteria are out of scope for M3. Membership cannot be read without a per-group installed OAuth token, so EasyCLA evaluates it in neither direction - no group check when granting, no GitlabOrgCriteria branch when removing. The Self Serve picker began offering GitLab group in lfx-self-serve#2257, which presents a silent no-op as a working control; the gap now calls for removing it until the backend can evaluate it. GitLab username is unaffected. Revoked outranks every other status, not just Signed - it is a fact about the whole signing entity, so it is evaluated first. flaggedAt is EasyCLA's detection date (the company's stored sanctioned_date, re-stamped if a cleared entity is flagged again), not the sanctioning authority's listing date. Also drops the stale "carries only two booleans" claim from the coverage gap and notes that the prototype has adopted Revoked, leaving the shipped build the only holdout on "Sanctioned"/"Unavailable". Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 41 ++++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 6c233be56..5b8def630 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -54,8 +54,11 @@ Rules: sanctions fields. Picking an unsigned CLA group there opens a transient detail page with managers and approval tabs locked; it is never a row in the org's CLA list and a reload loses it. Treating it as a persisted status would imply the list API can return it. -- **Revoked wins over Signed**, exactly as in M2 — the more consequential fact and the more - restrictive status, since the entry becomes read-only. +- **Revoked wins over every other status.** Revoked means the signing entity is under a + sanctions block — a fact about the whole organization, not about any one agreement — so it + outranks Signed and Not started alike and is evaluated first. M2 applies the same reasoning + where it ranks Revoked above Invalidated: the sanction is the more consequential fact and + Revoked is the more restrictive status, since the entry becomes read-only. - **Signing a CCLA is blocked while the signing entity is sanctioned**, so a **Revoked** entry can never become **Signed**, and *Sign CLA* must be unavailable from a **Not started** preview for a sanctioned entity. `v2/sign/service.go` enforces this at two points via @@ -110,7 +113,7 @@ Shown per contributor row in the employee acknowledgment table of a CLA entry. |---|---|---|---| | **Authorized** | The employee acknowledged the corporate agreement and the company's approval criteria still cover them. | `signatureSigned = true` **and** `signatureApproved = true` | no | | **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back restores them. | **none today** — no coverage verdict is computed, see [Not yet implemented](#not-yet-implemented) | no | -| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: `autoCreateECLA`, below. | `signatureApproved = false` | yes — *Invalidated · date* (not exposed on this row today) | +| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: `autoCreateECLA`, below. | `signatureApproved = false` | yes — *Invalidated · date*, from `invalidatedAt` | Rules: @@ -129,10 +132,13 @@ Rules: **Invalidated** row silently returns to **Authorized** on the next approval-list edit, including one a CLA manager invalidated deliberately. The target model treats Invalidated as final; see [Not yet implemented](#not-yet-implemented). -- **Invalidated does not name who did it** unless the record says so. New invalidations store - `InvalidationMetadata` (`InvalidatedBy`, `Reason`, `Note`) and the M3 invalidate endpoint - takes a `reason` enum plus a free-text `note`, but older records carry nothing — attribution - is per-record, never assumed. Same constraint as M2, narrower blast radius. +- **Only the invalidation date is shown; the row names nobody.** The API exposes the full set — + `corporate-contributor` carries `invalidatedAt`, `invalidatedBy`, `invalidationReason`, + `invalidationNote` and the legacy `note`, all populated by `GetClaGroupCorporateContributors` + — but the Org lens renders the **date alone**, matching the M3 prototype and M2's + *Invalidated · date* pill. Actor and reason stay available for support and the activity log, + not the status cell. Older records carry nothing at all, so attribution is per-record and + never assumed — the same constraint as M2. ### Which status an acknowledgment row gets @@ -149,11 +155,12 @@ Removing an approval criterion **invalidates matching acknowledgments immediatel criteria — `invalidateSignatures` sets `signature_approved = false` with invalidation metadata — so the row lands in **Invalidated** rather than in a recoverable state. What **Not Authorized** is meant to name is the residue: cases where coverage went stale and nothing -recorded it, so the row keeps reading **Authorized**. Those are GitLab group removals -(which invalidate nothing), membership drift in an approved GitHub org or GitLab group (no -approval-list edit occurs, so nothing triggers a re-check), and records the sweep skipped -(missing user record, or a panic recovered per acknowledgment). The specific backend defects -behind each are listed in [Not yet implemented](#not-yet-implemented). +recorded it, so the row keeps reading **Authorized**. Those are membership drift in an approved +GitHub org (no approval-list edit occurs, so nothing triggers a re-check) and records the sweep +skipped (missing user record, or a panic recovered per acknowledgment). GitLab groups are not a +source of residue because they are never evaluated in either direction — they are out of scope +for M3 entirely. The specific backend defects behind each are listed in +[Not yet implemented](#not-yet-implemented). Consequently the prototype's recovery hint — *add the user to the approval list, or Invalidate to remove for good* — is untruthful for the common case: the acknowledgment is already voided, @@ -205,17 +212,17 @@ there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuse | Gap | Effect in the Org lens | Backend status | |---|---|---| -| **Shipped build labels the sanctions state "Sanctioned", not "Revoked"** | Card pill reads *Sanctioned*, detail heading reads *Unavailable* — one company-level fact, three words across two lenses. | frontend copy only — rename to **Revoked** | +| **Shipped build labels the sanctions state "Sanctioned", not "Revoked"** | `cla.constants.ts` renders the card pill as *Sanctioned* and the detail heading as *Unavailable* — two words for one company-level fact, neither matching the Me lens. The M3 prototype has since adopted **Revoked** for the pill, so the build is now the only holdout. | frontend copy only — rename to **Revoked**, matching this document and the prototype | | **Shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed in the Self Serve server layer, relocating the two-boolean derivation this matrix criticizes. The Me lens consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field | | **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API. Worse, the page query filters on company only while `totalCount` also filters `signature_approved` and `signature_signed`, so unsigned and invalidated records consume page slots and cursors while being excluded from the total. Client-side dropping yields short pages and a mismatched count. | needs filing — both queries must select the **same** row set: require `signature_signed = true` in each and **keep** signed rows with `signature_approved = false`, which are the **Invalidated** rows the lens must show. A frontend-only fix is insufficient | | **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final; it is not. A deliberately invalidated row returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — likely backend bug; auto-create should not re-approve invalidated records | | **`signedOn` may be a creation date** | The list service substitutes `signature_created` when the signature has no `SignedOn`, and nothing flags the difference — breaking M2's "a wrong date is worse than none". | needs filing — omit the field when no signing timestamp exists, or mark it approximate | -| **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered. `corporate-contributor` carries only `signatureSigned` and `signatureApproved`; no equivalent of the M2 coverage check exists. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | -| **Invalidation date, reason and actor are not exposed** | **Invalidated** renders without its date or cause, even where `InvalidationMetadata` recorded both. | metadata is stored; the row model exposes none of it | +| **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered. `corporate-contributor` carries the two signature flags and the invalidation attributes, but nothing stating whether the approval criteria still cover the contributor — no equivalent of the M2 coverage check exists. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | +| **The prototype formats the invalidation date differently from M2** | The M3 prototype renders a full timestamp under the pill — *on Sep 17, 2026, 3:42:07 PM* — where the shipped Me lens shows *Invalidated · Jun 3, 2026*. One field, two formats across the lenses. | backend done — `invalidatedAt` is on the row; frontend copy only, adopt M2's date-only *Invalidated · date* | | **The signing refusal is not machine-readable** | Both CCLA signing gates return a plain error, not the typed `403 company_sanctioned` body the write ops return, so the Org lens would have to string-match — the same fragility as `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path | -| **Revoked has no date** | The entry shows the status alone; the list entry carries only the boolean `sanctioned`. The Me lens dates it from `flaggedAt`. | needs filing | +| **Revoked has no date** | The entry shows the status alone; the list entry carries only the boolean `sanctioned`, and the M3 prototype shows no date either. The Me lens model dates it from `flaggedAt` — the company's stored `sanctioned_date`, stamped when EasyCLA first detected the block and re-stamped if a cleared entity is flagged again, so it is EasyCLA's detection date and not the sanctioning authority's listing date. | needs filing — expose the stored date on the list entry | | **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md). | needs filing — document the endpoint | -| **GitLab group removals invalidate nothing** | A contributor removed from an approved GitLab group keeps an **Authorized** row: `verifyUserApprovals` has no `GitlabOrgCriteria` branch, and the GitLab-org block never populates the acknowledgments to iterate. Carried over from M2. | needs filing | +| **GitLab group criteria are not evaluable, so they are out of scope for M3** | GitLab group membership cannot be read without a per-group installed OAuth token, so EasyCLA never evaluates it: `EvaluateUserApproval` has no group check when granting coverage, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch when removing it. A GitLab group entry therefore grants nothing and, on removal, invalidates nothing. The Self Serve approval-list picker nonetheless offers **GitLab group** ([lfx-self-serve#2257](https://github.com/linuxfoundation/lfx-self-serve/pull/2257)), presenting a silent no-op as a working control. Carried over from M2, which recorded the same limitation. | needs filing — remove or disable **GitLab group** in the picker until the backend can evaluate it. GitLab *username* is unaffected and stays supported | | **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList`; only the domain block assigns `ECLAs` and neither org block clears it. An org removal alone sweeps nothing; combined with a domain removal in the same request it sweeps the domain-derived set under the org criterion — the only way the GitHub-org branch runs at all. | needs filing — backend bug; the fix has two halves. Isolating per-block state stops the wrong-set sweep but **alone makes a standalone org removal invalidate nothing**, since the org blocks never load acknowledgments. They must also load the set the removed org selects before calling `invalidateSignatures` | | **GitHub-org removal over-invalidates** | When it runs, the `GitHubOrgCriteria` branch checks only the email and GitHub-username lists instead of the full `userStillApproved` re-check, comparing case-sensitively against a single `getBestEmail(user)`. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry or a secondary email is invalidated anyway. | needs filing — backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The entry vanishes from the list, so an admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question | From a1e452b8a9726c976fc84697a8bc59aa4dc2d4f9 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 15:42:56 -0700 Subject: [PATCH 10/11] docs(review): correct the GitLab gap to pre-existing backend behavior The previous wording implied lfx-self-serve#2257 introduced the problem by offering GitLab group in the picker. It did not. That PR added approval-list management - CRUD over the six criteria lists the producer already keeps - so a GitLab group entry is stored and listed, nothing more. The inertness lives in the easycla backend, which #2257 did not touch, and dates to easycla#3081: group membership needs a per-group OAuth token, so neither the granting path nor the removal sweep ever evaluates the criterion. The Corporate Console has always offered it on the same terms. Reframed as long-standing backend behavior that Self Serve inherits, and pointed the action at the backend - evaluate the entry or reject it at the API - with hiding it in the Org lens as the interim measure rather than the fix. Co-Authored-By: Claude Opus 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index 5b8def630..ea9b71aef 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -222,7 +222,7 @@ there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuse | **The signing refusal is not machine-readable** | Both CCLA signing gates return a plain error, not the typed `403 company_sanctioned` body the write ops return, so the Org lens would have to string-match — the same fragility as `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path | | **Revoked has no date** | The entry shows the status alone; the list entry carries only the boolean `sanctioned`, and the M3 prototype shows no date either. The Me lens model dates it from `flaggedAt` — the company's stored `sanctioned_date`, stamped when EasyCLA first detected the block and re-stamped if a cleared entity is flagged again, so it is EasyCLA's detection date and not the sanctioning authority's listing date. | needs filing — expose the stored date on the list entry | | **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md). | needs filing — document the endpoint | -| **GitLab group criteria are not evaluable, so they are out of scope for M3** | GitLab group membership cannot be read without a per-group installed OAuth token, so EasyCLA never evaluates it: `EvaluateUserApproval` has no group check when granting coverage, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch when removing it. A GitLab group entry therefore grants nothing and, on removal, invalidates nothing. The Self Serve approval-list picker nonetheless offers **GitLab group** ([lfx-self-serve#2257](https://github.com/linuxfoundation/lfx-self-serve/pull/2257)), presenting a silent no-op as a working control. Carried over from M2, which recorded the same limitation. | needs filing — remove or disable **GitLab group** in the picker until the backend can evaluate it. GitLab *username* is unaffected and stays supported | +| **GitLab group criteria are stored but never evaluated, so they are out of scope for M3** | Group membership cannot be read without a per-group installed OAuth token, so EasyCLA evaluates a GitLab group entry in neither direction: `EvaluateUserApproval` has no group check when granting coverage, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch when removing it. The entry is stored and listed, but grants nothing and, on removal, invalidates nothing. This is long-standing backend behavior ([easycla#3081](https://github.com/linuxfoundation/easycla/pull/3081) added the lists), not new — the Corporate Console has always offered the criterion on the same terms, and the Self Serve approval list ([lfx-self-serve#2257](https://github.com/linuxfoundation/lfx-self-serve/pull/2257)) inherits it by exposing the same producer field. Carried over from M2, which recorded the same limitation. | needs filing against the backend — a group entry must either be evaluated or be rejected at the API. Until then the Org lens should not offer **GitLab group**, since a stored-but-inert criterion reads as working coverage. GitLab *username* is unaffected and stays supported | | **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList`; only the domain block assigns `ECLAs` and neither org block clears it. An org removal alone sweeps nothing; combined with a domain removal in the same request it sweeps the domain-derived set under the org criterion — the only way the GitHub-org branch runs at all. | needs filing — backend bug; the fix has two halves. Isolating per-block state stops the wrong-set sweep but **alone makes a standalone org removal invalidate nothing**, since the org blocks never load acknowledgments. They must also load the set the removed org selects before calling `invalidateSignatures` | | **GitHub-org removal over-invalidates** | When it runs, the `GitHubOrgCriteria` branch checks only the email and GitHub-username lists instead of the full `userStillApproved` re-check, comparing case-sensitively against a single `getBestEmail(user)`. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry or a secondary email is invalidated anyway. | needs filing — backend bug, not a display gap | | **An invalidated CCLA disappears silently** | The entry vanishes from the list, so an admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question | From f7d7eda95cd0fa415db0e5df7ac5c9fd7c90b980 Mon Sep 17 00:00:00 2001 From: Michal Lehotsky Date: Thu, 17 Sep 2026 18:50:24 -0700 Subject: [PATCH 11/11] fix(review): address PR #5211 round-3 review feedback Address review comments from copilot-pull-request-reviewer[bot]: - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the signedOn claim - the Org-lens list service now reads storedSignedOn directly and returns no date when absent, rather than falling back to signature_created (v2/company/service.go:1478-1486,1542-1551) - docs/M3_ORG_LENS_STATUS_MATRIX.md: correct the autoCreateECLA claim - processEmployeeSignatures now checks Invalidated first and leaves invalidated records alone instead of re-approving them (signatures/service.go:902-913) - docs/M3_ORG_LENS_STATUS_MATRIX.md: narrow the signing-refusal gap to the DocuSign completion callback only - the initial sign-request endpoint already returns the typed 403 company_sanctioned body (v2/sign/handlers.go:128-135 vs 310-325) - docs/M3_ORG_LENS_STATUS_MATRIX.md: drop the "search endpoint undocumented" gap - GET /v4/cla-group/search already has a full section in M3_ORG_LENS_API.md; added a cross-link instead - docs/M3_ORG_LENS_STATUS_MATRIX.md: narrow the "org removals iterate the wrong acknowledgment set" gap to the GitLab-org branch - the GitHub-org branch now loads its own removed-org ECLAs and assigns them explicitly before invalidating (signatures/repository.go:3900-3910 vs 4045-4090) - docs/M3_ORG_LENS_STATUS_MATRIX.md: drop the "GitHub-org removal over-invalidates" gap - it now uses stillCovered with case-insensitive checks across email, domain, GitHub username, GitLab username, and remaining org coverage (signatures/repository.go:4609-4623,4730-4744) Resolves 6 review threads. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Michal Lehotsky --- docs/M3_ORG_LENS_STATUS_MATRIX.md | 50 ++++++++++++++----------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/M3_ORG_LENS_STATUS_MATRIX.md b/docs/M3_ORG_LENS_STATUS_MATRIX.md index ea9b71aef..e2cf233a1 100644 --- a/docs/M3_ORG_LENS_STATUS_MATRIX.md +++ b/docs/M3_ORG_LENS_STATUS_MATRIX.md @@ -50,10 +50,11 @@ Rules: agreements are never shown. Consequently `signed` is always `true` on a returned entry: a sanity check, not a discriminator. - **Not started exists only as a preview.** It is reachable solely through **Sign CLA**, the - unscoped catalog search backed by `GET /cla-group/search`, which carries no signature or - sanctions fields. Picking an unsigned CLA group there opens a transient detail page with - managers and approval tabs locked; it is never a row in the org's CLA list and a reload - loses it. Treating it as a persisted status would imply the list API can return it. + unscoped catalog search backed by [`GET /v4/cla-group/search`](M3_ORG_LENS_API.md), which + carries no signature or sanctions fields. Picking an unsigned CLA group there opens a + transient detail page with managers and approval tabs locked; it is never a row in the org's + CLA list and a reload loses it. Treating it as a persisted status would imply the list API + can return it. - **Revoked wins over every other status.** Revoked means the signing entity is under a sanctions block — a fact about the whole organization, not about any one agreement — so it outranks Signed and Not started alike and is evaluated first. M2 applies the same reasoning @@ -83,10 +84,10 @@ Rules: banned copy**. Both carry over from M2 unchanged. - **A name is shown only when a real one was recorded.** `signedBy` is omitted when the CCLA carries no `SignatoryName` — no CLA-manager fallback — so the line degrades to *Signed on - {date}* rather than naming the wrong person. **The date does not yet hold to that standard**: - the list service substitutes `signature_created` when the signature has no `SignedOn`, so - *Signed on {date}* can present a creation date as a signing date, breaking M2's "a wrong date - is worse than none" rule. See [Not yet implemented](#not-yet-implemented). + {date}* rather than naming the wrong person. **The date now holds to that standard too**: + the Org-lens list service (`storedSignedOn`) reads the CCLA's stored `SignedOn` directly and + returns no date when it is absent, rather than substituting `signature_created` — the shared + v1 converter's fallback, which is a corporate-console contract this lens does not inherit. ### Which status a CLA entry gets @@ -113,7 +114,7 @@ Shown per contributor row in the employee acknowledgment table of a CLA entry. |---|---|---|---| | **Authorized** | The employee acknowledged the corporate agreement and the company's approval criteria still cover them. | `signatureSigned = true` **and** `signatureApproved = true` | no | | **Not Authorized** | The acknowledgment is intact, but the contributor is no longer covered by the approval criteria. Nobody revoked access deliberately — a criterion they matched was removed, or their membership of an approved org or group changed. **Recoverable**: adding them back restores them. | **none today** — no coverage verdict is computed, see [Not yet implemented](#not-yet-implemented) | no | -| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. One exception today: `autoCreateECLA`, below. | `signatureApproved = false` | yes — *Invalidated · date*, from `invalidatedAt` | +| **Invalidated** | The acknowledgment itself was made void — deliberately by a CLA manager, or as a side effect of an approval-criterion removal. **Not recoverable** by re-adding the contributor; they must acknowledge again. | `signatureApproved = false` | yes — *Invalidated · date*, from `invalidatedAt` | Rules: @@ -126,12 +127,12 @@ Rules: splitting them: one is coverage drift the company can undo, the other a voided agreement it cannot. Copy and severity must not blur them — the prototype renders the first amber, the second red. -- **`autoCreateECLA` breaks that rule today.** With auto-create enabled, an approval-list - update calls `CreateOrUpdateEmployeeSignature` → `ValidateProjectRecord`, which sets - `signature_approved = true` on every acknowledgment where either flag is false. So an - **Invalidated** row silently returns to **Authorized** on the next approval-list edit, - including one a CLA manager invalidated deliberately. The target model treats Invalidated as - final; see [Not yet implemented](#not-yet-implemented). +- **`autoCreateECLA` now honors that rule.** `processEmployeeSignatures` checks + `employeeModel.Invalidated` first and leaves an invalidated record alone, logging that it + needs an explicit re-approval; only a record that was never invalidated and has either flag + false is re-approved, via `ValidateProjectRecordUnlessInvalidated`. An **Invalidated** row no + longer returns to **Authorized** on an unrelated approval-list edit. The target model's + "Invalidated is final" rule now matches backend behavior for this path. - **Only the invalidation date is shown; the row names nobody.** The API exposes the full set — `corporate-contributor` carries `invalidatedAt`, `invalidatedBy`, `invalidationReason`, `invalidationNote` and the legacy `note`, all populated by `GetClaGroupCorporateContributors` @@ -164,11 +165,10 @@ for M3 entirely. The specific backend defects behind each are listed in Consequently the prototype's recovery hint — *add the user to the approval list, or Invalidate to remove for good* — is untruthful for the common case: the acknowledgment is already voided, -so re-adding the criterion does not restore the row. The hint only describes reality where -`autoCreateECLA` is enabled, and there it works by re-approving invalidated records -indiscriminately rather than by any coverage logic. Either the row needs a live coverage -verdict from the backend, or removal must stop auto-invalidating. This is an open product -decision, not a copy fix. +so re-adding the criterion does not restore the row. `autoCreateECLA` no longer offers a +backdoor to it either — `processEmployeeSignatures` now leaves an invalidated record alone +rather than re-approving it. The row needs a live coverage verdict from the backend before Not +Authorized can render. This is an open product decision, not a copy fix. ## Cross-lens naming map @@ -215,18 +215,14 @@ there at all, so everything in [Acknowledgment statuses](#acknowledgment-statuse | **Shipped build labels the sanctions state "Sanctioned", not "Revoked"** | `cla.constants.ts` renders the card pill as *Sanctioned* and the detail heading as *Unavailable* — two words for one company-level fact, neither matching the Me lens. The M3 prototype has since adopted **Revoked** for the pill, so the build is now the only holdout. | frontend copy only — rename to **Revoked**, matching this document and the prototype | | **Shipped build derives the entry status from two booleans in the BFF** | `signed` and `sanctioned` are collapsed in the Self Serve server layer, relocating the two-boolean derivation this matrix criticizes. The Me lens consumes an authoritative `status` from the producer. | open — whether the entry status should become a producer-side field | | **Unsigned acknowledgments are not filtered server-side, and the count disagrees with the page** | Rows with `signatureSigned = false` come back from the API. Worse, the page query filters on company only while `totalCount` also filters `signature_approved` and `signature_signed`, so unsigned and invalidated records consume page slots and cursors while being excluded from the total. Client-side dropping yields short pages and a mismatched count. | needs filing — both queries must select the **same** row set: require `signature_signed = true` in each and **keep** signed rows with `signature_approved = false`, which are the **Invalidated** rows the lens must show. A frontend-only fix is insufficient | -| **`autoCreateECLA` resurrects invalidated acknowledgments** | The target model treats **Invalidated** as final; it is not. A deliberately invalidated row returns to **Authorized** on the next unrelated approval-list edit, with only a `note` recording it. | needs filing — likely backend bug; auto-create should not re-approve invalidated records | -| **`signedOn` may be a creation date** | The list service substitutes `signature_created` when the signature has no `SignedOn`, and nothing flags the difference — breaking M2's "a wrong date is worse than none". | needs filing — omit the field when no signing timestamp exists, or mark it approximate | | **No coverage verdict on an acknowledgment row** | **Not Authorized** cannot be rendered. `corporate-contributor` carries the two signature flags and the invalidation attributes, but nothing stating whether the approval criteria still cover the contributor — no equivalent of the M2 coverage check exists. | needs filing — a per-row coverage verdict, or a documented decision that removal stops auto-invalidating | | **The prototype formats the invalidation date differently from M2** | The M3 prototype renders a full timestamp under the pill — *on Sep 17, 2026, 3:42:07 PM* — where the shipped Me lens shows *Invalidated · Jun 3, 2026*. One field, two formats across the lenses. | backend done — `invalidatedAt` is on the row; frontend copy only, adopt M2's date-only *Invalidated · date* | -| **The signing refusal is not machine-readable** | Both CCLA signing gates return a plain error, not the typed `403 company_sanctioned` body the write ops return, so the Org lens would have to string-match — the same fragility as `TODO(#5078)`. | needs filing — return the typed sanctions error from the signing path | +| **The DocuSign-completion signing refusal is not machine-readable** | `SignRequestCorporateSignatureHandler` already maps a sanctions block to the typed `403 company_sanctioned` body, but `SignCclaCallbackHandler` still returns a plain `400` with no typed error, so the Org lens would have to string-match on that one path — the same fragility as `TODO(#5078)`. | needs filing — return the typed sanctions error from the callback path too | | **Revoked has no date** | The entry shows the status alone; the list entry carries only the boolean `sanctioned`, and the M3 prototype shows no date either. The Me lens model dates it from `flaggedAt` — the company's stored `sanctioned_date`, stamped when EasyCLA first detected the block and re-stamped if a cleared entity is flagged again, so it is EasyCLA's detection date and not the sanctioning authority's listing date. | needs filing — expose the stored date on the list entry | -| **The Sign CLA search endpoint is undocumented** | `GET /cla-group/search` backs the **Not started** preview but has no section in [M3_ORG_LENS_API.md](M3_ORG_LENS_API.md). | needs filing — document the endpoint | | **GitLab group criteria are stored but never evaluated, so they are out of scope for M3** | Group membership cannot be read without a per-group installed OAuth token, so EasyCLA evaluates a GitLab group entry in neither direction: `EvaluateUserApproval` has no group check when granting coverage, and `verifyUserApprovals` has no `GitlabOrgCriteria` branch when removing it. The entry is stored and listed, but grants nothing and, on removal, invalidates nothing. This is long-standing backend behavior ([easycla#3081](https://github.com/linuxfoundation/easycla/pull/3081) added the lists), not new — the Corporate Console has always offered the criterion on the same terms, and the Self Serve approval list ([lfx-self-serve#2257](https://github.com/linuxfoundation/lfx-self-serve/pull/2257)) inherits it by exposing the same producer field. Carried over from M2, which recorded the same limitation. | needs filing against the backend — a group entry must either be evaluated or be rejected at the API. Until then the Org lens should not offer **GitLab group**, since a stored-but-inert criterion reads as working coverage. GitLab *username* is unaffected and stays supported | -| **Org removals iterate the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList`; only the domain block assigns `ECLAs` and neither org block clears it. An org removal alone sweeps nothing; combined with a domain removal in the same request it sweeps the domain-derived set under the org criterion — the only way the GitHub-org branch runs at all. | needs filing — backend bug; the fix has two halves. Isolating per-block state stops the wrong-set sweep but **alone makes a standalone org removal invalidate nothing**, since the org blocks never load acknowledgments. They must also load the set the removed org selects before calling `invalidateSignatures` | -| **GitHub-org removal over-invalidates** | When it runs, the `GitHubOrgCriteria` branch checks only the email and GitHub-username lists instead of the full `userStillApproved` re-check, comparing case-sensitively against a single `getBestEmail(user)`. A contributor still covered by a domain rule, a GitLab username, a differently-cased entry or a secondary email is invalidated anyway. | needs filing — backend bug, not a display gap | +| **GitLab-org removal iterates the wrong acknowledgment set** | `UpdateApprovalList` mutates one shared `ApprovalList`. The GitHub-org removal branch now loads its own removed-org ECLAs and assigns `.ECLAs` explicitly before invalidating, so a standalone GitHub-org removal sweeps correctly. The GitLab-org branch still never assigns `.ECLAs`, so a standalone GitLab-org removal sweeps nothing; combined with a domain removal in the same request it sweeps the domain-derived set under the GitLab-org criterion instead. | needs filing — backend bug scoped to the GitLab-org branch; it must load the acknowledgment set the removed GitLab org selects before calling `invalidateSignatures`, matching the GitHub-org branch's fix | | **An invalidated CCLA disappears silently** | The entry vanishes from the list, so an admin cannot tell a never-signed CLA group from one whose agreement was voided. | open product question | -| **Sanctioned + never signed is not listable** | Only the **Sign CLA** preview can show it, and `GET /cla-group/search` carries no `sanctioned` field, so the preview must resolve the sanctions state separately. | needs filing — add the flag to the search result, or have the preview resolve the company | +| **Sanctioned + never signed is not listable** | Only the **Sign CLA** preview can show it, and `GET /v4/cla-group/search` carries no `sanctioned` field, so the preview must resolve the sanctions state separately. | needs filing — add the flag to the search result, or have the preview resolve the company | | **Invalidating existing acknowledgments on sanction is blocked** | A newly sanctioned company keeps **Authorized** acknowledgment rows while every write is refused. | blocked pending [lfx-self-serve#2051](https://github.com/linuxfoundation/lfx-self-serve/issues/2051) | | **`needsClaManager` and `autoCreateECLA` are flags, not statuses** | Both are on the list entry and render as their own affordances on the card; they do not participate in the status model. | done — out of scope |