diff --git a/docs/easycla-ss-migration/README.md b/docs/easycla-ss-migration/README.md
new file mode 100644
index 000000000..7a08497db
--- /dev/null
+++ b/docs/easycla-ss-migration/README.md
@@ -0,0 +1,12 @@
+
+
+# EasyCLA → LFX Self Serve Migration — Architecture Review
+
+Self-contained materials for the architecture review of the EasyCLA-to-Self-Serve migration. Reading order:
+
+1. **[architecture-proposal.md](architecture-proposal.md)** — start here. Current state, milestones, what leadership already settled, the proposed architecture (P1–P8), top risks, and what the review should challenge.
+2. **[role-mapping-feasibility.md](role-mapping-feasibility.md)** — the supporting deep analysis for the roles/permissions bridge (P2/P3): how EasyCLA v4 authorization actually works, token paths, read paths, options assessment, and the spike list. All claims cite `file:line`.
+3. **[Slide deck (Google Slides)](https://docs.google.com/presentation/d/1FQJOpiETIO_H10c6_eP2Zu-LM7qlvG_t7blhRm--2KA/edit)** — presentation for the review session.
+
+Implementation-level specifications (milestone scopes, acceptance criteria, per-milestone plans — used by the Spec Kit workflow) live separately in [specs/001-easycla-ss-integration-fable/](../../specs/001-easycla-ss-integration-fable/spec.md). This folder is for evaluating the architecture; that folder is for building it.
diff --git a/docs/easycla-ss-migration/architecture-proposal.md b/docs/easycla-ss-migration/architecture-proposal.md
new file mode 100644
index 000000000..a168d6229
--- /dev/null
+++ b/docs/easycla-ss-migration/architecture-proposal.md
@@ -0,0 +1,106 @@
+
+
+# EasyCLA → LFX Self Serve: Architecture Proposal
+
+**Status**: Reviewed by architecture (Eric Searcy, 2026-07-20) — feedback incorporated below (P2/P3 adjusted, P9 and one risk added). P10 subsequently approved 2026-07-28 and confirmed 2026-07-30 (see P10).
+**Owner**: Michal (engineering) | **Date**: 2026-07-20 (P10 updated 2026-07-30)
+
+This document is self-contained for review purposes. Implementation-level specs (milestone scopes, acceptance criteria, M1 plan) live in [specs/001-easycla-ss-integration-fable/](../../specs/001-easycla-ss-integration-fable/spec.md) — linked below only for deep dives.
+
+## 1. What the program is
+
+Migrate all EasyCLA user-facing functionality — Contributor Console, Corporate CLA Console (plus its Node/Apollo GraphQL BFF), the PCC EasyCLA module, and the landing page — into **LFX Self Serve** under its Me / Organization / Project lenses. The backend re-platform (M6: Lambda → Kubernetes V2 service, optional DynamoDB → Postgres) is a **separately gated decision**, not part of this proposal.
+
+```mermaid
+flowchart LR
+ subgraph TODAY["Today: four UIs being absorbed"]
+ CTC["Contributor Console"]
+ CCC["Corporate CLA Console
+ GraphQL BFF"]
+ PCC["PCC EasyCLA module"]
+ LP["Landing page"]
+ end
+ subgraph TARGET["Target: LFX Self Serve"]
+ ME["Me lens (M1-M3)"]
+ ORG["Organization lens (M4)"]
+ PRJ["Project lens (M5)"]
+ end
+ subgraph BACKEND["EasyCLA backend (unchanged until M6)"]
+ GW["lfx-gateway"]
+ V4["cla-backend-go
/v3 + /v4 Lambdas"]
+ LEG["cla-backend-legacy
/v1 + /v2"]
+ DDB[("DynamoDB
19 tables + S3 PDFs")]
+ DS["DocuSign"]
+ ACS2["ACS roles
(LFX v1 service, own DB)"]
+ end
+ CTC --> GW
+ CCC --> GW
+ PCC --> GW
+ ME --> GW
+ ORG --> GW
+ PRJ --> GW
+ CTC --> LEG
+ ME --> LEG
+ GW --> V4
+ V4 --> DDB
+ V4 --> DS
+ V4 --> ACS2
+ LP -.->|"retired at M3"| TARGET
+```
+
+Current-state facts the proposal relies on (verified in code; details in the [feasibility memo](role-mapping-feasibility.md)):
+
+- EasyCLA already routes through **lfx-gateway** (`/cla-service/v3|v4` → Lambda). It is behind the platform gateway today; nothing needs onboarding.
+- **Two Go API surfaces**: `cla-backend-go` (`/v3`, `/v4`) and `cla-backend-legacy` (`/v1`, `/v2` — contributor flows still call it; the old Python backend is gone).
+- **DocuSign integration lives server-side** (`v2/sign`): consoles only fetch a `sign_url` and redirect; webhooks/PDFs/state never touch the UIs.
+- **CLA roles** (`cla-manager`, `cla-signatory`, `cla-manager-designee`) are ACS role+scope tuples (ACS is an LFX v1 component with its own database), assigned asynchronously. LFX V2 authorization is OpenFGA relations; **no CLA object types exist in the FGA model**.
+- The PR status-check redirect URL is an **SSM parameter per environment** — contributor cutover is a config flip.
+
+## 2. Milestones (context)
+
+| # | Milestone | Retires | Effort |
+|---|-----------|---------|--------|
+| [M1](../../specs/001-easycla-ss-integration-fable/01-milestone-read-only-me-lens-fable.md) | Read-only "My CLAs" (Me lens) | — | S |
+| [M2](../../specs/001-easycla-ss-integration-fable/02-milestone-sign-icla-fable.md) | Sign ICLA in SS (a: GitHub, b: GitLab, c: Gerrit) | — | M |
+| [M3](../../specs/001-easycla-ss-integration-fable/03-milestone-sign-ecla-fable.md) | Sign ECLA in SS (a–c per platform) | Contributor Console + landing page | L |
+| [M4](../../specs/001-easycla-ss-integration-fable/04-milestone-ccla-org-lens-fable.md) | CCLA management (Organization lens) | Corporate Console + its BFF | XL |
+| [M5](../../specs/001-easycla-ss-integration-fable/05-milestone-project-lens-pcc-fable.md) | EasyCLA admin (Project lens) — *decision-gated* | PCC EasyCLA module | L |
+| [M6](../../specs/001-easycla-ss-integration-fable/06-milestone-k8s-v2-api-fable.md) | API → Kubernetes V2 service (± Postgres) — *separately gated* | Lambda/API GW stack | XL–XXL |
+
+## 3. Already settled at the leadership review (2026-07-15) — context, not up for review
+
+| # | Decision |
+|---|---|
+| L1 | **UI-first sequencing approved**: M1–M5 build on the existing EasyCLA APIs; M6 gated separately |
+| L2 | Target timeline: **Q3 / early Q4 2026** |
+| L3 | **M5 is decision-gated**: PCC EasyCLA admin moves to SS *or stays in PCC* — open product decision (Kieran/Manish/Heather) |
+| L4 | EasyCLA **landing page retired** (added to the M3 decommission package) |
+| L5 | LFID account is a prerequisite for signing (already the status quo in the consoles) |
+| L6 | Post-signing redirect to the SS profile (collect GitHub linking *after* signing, no friction before) |
+
+## 4. Proposed architecture (for review)
+
+| # | Proposal | Rationale (short) |
+|---|---|---|
+| P1 | **Strangler pattern: SS is a new client of the existing EasyCLA v4/v3 APIs.** One SS server-side `cla` module; no business logic reimplemented; enforcement (roles, approval lists, sanctions) stays in EasyCLA until M6 | One source of truth; follows the proven crowdfunding integration shape in SS |
+| P2 | **Roles: bridge, don't migrate.** No CLA object types in OpenFGA before M6. SS gates UI via the user's **self permission check** (`POST user-service/v1/me/permissions/checks` — the same ACS decision the gateway enforces), handling v4 403s gracefully; the public manager-list endpoint supplies display data and post-assignment pending-state UX. Every write is still enforced by the gateway/ACS + v4 | Self-check guarantees UI gating and API enforcement agree by construction (review guidance); an FGA copy would be non-enforcing, with sync lag on an already-async pipeline; evidence in the [feasibility memo](role-mapping-feasibility.md). Endpoint-deprecation risk closed (Eric, 2026-07-31, ARCH-406): `/v1/me/permissions/checks` has no decommission timeline — it stays for as long as the v1 gateway/ACS exist, so the M4/M5 (~Q4 2026) dependency is safe |
+| P3 | **Tokens: user-scoped access tokens (never ID tokens), via SS's existing api-gw-audience refresh-token exchange** — the same access/refresh authentication the me/project lenses use. No M2M by default, no token infrastructure work | The whole v4 chain keys on user identity, not client/audience — verified in code ([feasibility §4](role-mapping-feasibility.md)); two curl spikes remain. ID-token usage stays legacy-console-only; interim gateway/ACS/v4 support for both token types is acceptable during cutover. **Caller-identification + trust model for the "My CLAs" by-identity read endpoint: see P10.** |
+| P4 | **DocuSign never moves in M2–M4.** SS fetches a `sign_url` from v4 and redirects, exactly as the consoles do today; no DocuSign bridge service | Webhooks, PDF storage, envelope state already live in `v2/sign`; duplicating them adds risk with no user value |
+| P5 | **Cutover per milestone is a config flip** (SSM redirect base for the PR check; lens feature flags for org/project) — instant rollback | Reversibility is a program success criterion |
+| P6 | **All three git platforms in scope** via per-platform sub-milestones, each with its own cutover switch and parity checklist | Prevents Gerrit/GitLab slipping and blocking console retirement late |
+| P7 | **The legacy `/v1`/`/v2` Go surface stays until M6**, covered by parity/contract tests — contributor flows still call it | Second API codebase inside the blast radius even for "UI-only" milestones; absorbed/retired at M6 |
+| P8 | **Email-based CCLA signatory signing is preserved** — the signatory signs via an emailed DocuSign link, never forced into SS/LF SSO | Documented product behavior; a distinct UX path that must survive M4 |
+| P9 | **Audit v4 API payloads for v1 user-service/org-service IDs and plan the mapping lookups** (API shapes unchanged this phase). Users: resolve via the `lfx.lookup_v1_user_sfid.by_username` / `.by_email` NATS RPCs (lfx-v1-sync-helper); orgs: v1 org service via the api-gw secondary token | user-service and org-service are being deprecated in the LFX v2 transition (users collapse to email/username references; orgs to name/domain except true B2B orgs). SS UI must not hard-depend on v1 IDs it cannot resolve later |
+| P10 | **"My CLAs" identity read: EasyCLA trusts an SS-supplied identity list rather than re-verifying per request.** SS builds the list server-side via the auth service `lfx.auth-service.user_identity.list` NATS RPC (session user's own token; never client params, never SS→Auth0-Management directly), and EasyCLA confirms the caller is SS via an **`azp` allow-list of SS confidential-client IDs**, checked in the v4 handler, with **in-handler JWKS re-verification** and **deny on missing/unparseable bearer**; the endpoint stays strictly read-only. **Transitional (revisit at M6):** once EasyCLA is on the K8s cluster it should call the auth-service RPC itself over NATS and drop both the trusted payload and the `azp` mechanism | Reviewed & approved by architecture (Eric Searcy, 2026-07-28). It's "SS-queried Auth0 vs. EasyCLA-queried Auth0" — same data, same source — so trusting SS's list is the same data with less machinery (EasyCLA has no NATS transit pre-M6 — confirmed by Eric 2026-07-30: it's a request/reply inbox subject, so EasyCLA would have to be a cluster consumer for the reply, over a cross-region us-east-1 ↔ us-west-2 WAN link; a federated NATS super-cluster is overkill — and it would otherwise need a tenant-wide `read:users` M2M client on a hot path). `azp` allow-listing is sound **only** because SS is a confidential backend client whose tokens never reach a browser; if that client ID were reused by a public/SPA client the boundary silently collapses. Verifying the current EasyCLA-record check (P-note): historical GitHub-only signers have no `lf_username`, so record-based verification wrongly returns empty — the Auth0-sourced list fixes this. Decision: linuxfoundation/lfx-self-serve#1216; follow-ups linuxfoundation/lfx-self-serve#1224 (EasyCLA) / linuxfoundation/lfx-self-serve#1225 (SS). **Implementation prerequisite (found 2026-07-31, verified empirically in dev):** the auth service today verifies JWTs against the Auth0 Management API audience only and reuses the caller's JWT as the bearer for its own Auth0 Management API lookups — so it rejects SS session tokens (`invalid audience`; they carry the LFX v2 API audience, as do impersonation tokens). A small `lfx-v2-auth-service` change must land first: accept the LFX v2 API audience as valid for verification, and for such tokens use the verified `sub` with the service's own M2M credentials on read lookups (write subjects unchanged). Which SS token is passed (session access token vs. the P3 api-gw-audience token) is pending Eric's confirmation on linuxfoundation/lfx-self-serve#1216 |
+
+## 5. Top risks
+
+| Risk | Mitigation |
+|------|-----------|
+| Identity mapping gaps (LF account ↔ EasyCLA records, esp. pre-LF-login history) | M1 ships mapping + unmatched-user telemetry before any signing moves |
+| `azp`-allow-list trust boundary (P10) silently collapses if SS's confidential-client ID is ever reused by a public/SPA client, or its tokens become browser-visible | Code comment stating the assumption at the check; removed at M6 when EasyCLA calls the auth-service RPC directly (P10); read-only endpoint bounds worst case to CLA-match read-disclosure |
+| ACS role assignment is async, and warden responses are cached ~30 min (revocations linger too) | Server-side retries in SS; honest pending states; no synchronous-UX promises |
+| M4 scope illusion: "migrate a console" hides a ~648-file GraphQL BFF | Sized XL; inventory-driven parity checklist is the contract with PM |
+| Dual-console feature drift during migration | Freeze console feature work per area once its SS milestone starts |
+| M6 rework of M3–M5 adapters | All SS↔EasyCLA integration behind one server module |
+| CLA permissions are **invisible to the automatic docs generation** built on the OpenFGA + v2 Swagger sources of truth (raised at architecture review) | Document role-bridge behavior manually (M4 exit criterion); resolved when CLA enters OpenFGA at M6 |
diff --git a/docs/easycla-ss-migration/role-mapping-feasibility.md b/docs/easycla-ss-migration/role-mapping-feasibility.md
new file mode 100644
index 000000000..e6afd0ea8
--- /dev/null
+++ b/docs/easycla-ss-migration/role-mapping-feasibility.md
@@ -0,0 +1,241 @@
+
+
+# Roles/Permissions Mapping Feasibility — EasyCLA ↔ LFX Self Serve
+
+**For**: Heather (PM), Kieran (strategy), architecture review | **From**: Michal (engineering) | **Date**: 2026-07-15
+**Basis**: [00-overview-fable.md](../../specs/001-easycla-ss-integration-fable/00-overview-fable.md) §2.4/§3, [04-milestone-ccla-org-lens-fable.md](../../specs/001-easycla-ss-integration-fable/04-milestone-ccla-org-lens-fable.md) role options; code citations verified 2026-07-15, re-checked 2026-07-20
+**Answers**: the open engineering action from the 2026-07-15 leadership review ([spec.md](../../specs/001-easycla-ss-integration-fable/spec.md) "Program review outcomes")
+
+**How to read**: §0 is the answer. §1–§2 explain how EasyCLA authorization actually works (where most assumptions were wrong). §3–§5 are evidence tables. §6 is the decision. §7 is what to verify next. **[verified]** claims cite `file:line`; **[inferred]** claims are covered by a spike.
+
+---
+
+## 0. Executive answer
+
+**Feasible with conditions — and the conditions are smaller than we assumed.**
+
+Self Serve can call the existing EasyCLA v4 APIs with user-scoped tokens, **without any gateway or EasyCLA changes**, because the entire v4 authorization chain keys on the **user's identity (LF username)** — not on which Auth0 client or audience minted the token:
+
+- The gateway validates token **issuer only**; the audience check is explicitly disabled **[verified]**.
+- The gateway resolves the user's ACS permissions per request and injects them as an `X-ACL` header, which v4 trusts wholesale **[verified]**.
+- Self Serve **already ships** the needed token machinery: a secondary access token for the `api-gw.*.platform.linuxfoundation.org` audience — exactly the audience Auth0 stamps the required `lfx.dev/claims/username` claim onto **[verified]**.
+- The Corporate Console today sends an Auth0 **ID token** and it works — direct evidence that audience is irrelevant **[verified]**.
+
+The milestone-04 recommendation stands: **Option A (bridge) for M4/M5, OpenFGA modeling deferred to M6, org-admin mapping rejected** (§6).
+
+Two conditions to convert into facts before committing M3–M5 staffing — both are one-day curl spikes (§7):
+
+1. ACS's gateway-level path authorization must admit **ordinary users (no CLA role)** to the v4 *read* endpoints M1/M3 need.
+2. The refresh-token exchange for the api-gw audience must be **granted to the SS Auth0 client** in each environment.
+
+---
+
+## 1. Background: two authorization worlds
+
+The program bridges two authorization systems that share nothing — different models, stores, and sources of truth:
+
+```mermaid
+flowchart TB
+ subgraph EASYCLA["EasyCLA authorization world (today, enforcing)"]
+ ACS["ACS
role + scope tuples
cla-manager, cla-signatory, cla-manager-designee
(LFX v1 service, own Postgres)"]
+ SIGACL["DynamoDB signature ACL
(names on the CCLA signature record)"]
+ V4["EasyCLA v4 API
enforces on every write"]
+ ACS -->|"scopes injected as X-ACL
by lfx-gateway per request"| V4
+ SIGACL -->|"CurrentUserInACL check
(ecla-auto-create)"| V4
+ end
+ subgraph LFXV2["LFX V2 authorization world (Self Serve)"]
+ FGA["OpenFGA relations
b2b_org#writer, project#writer
(no CLA object types exist)"]
+ ORG["SS Organization lens"]
+ FGA --> ORG
+ end
+ ORG -.->|"Option A bridge:
SS calls v4 as another client,
v4 + gateway keep enforcing"| V4
+```
+
+Key mismatch (from milestone 04): an org-lens admin (`b2b_org#writer`) is **not** a CLA manager, and CLA authority is per **company × project/CLA group** — finer-grained than anything the org lens models. Note also that the EasyCLA side has **two** stores of manager truth (ACS scopes *and* the signature ACL) — this matters for UI gating (§5) and the OpenFGA option (§6).
+
+---
+
+## 2. How an EasyCLA v4 request is authorized today
+
+This is the load-bearing section. Enforcement happens at **two layers, and neither is "v4 checks roles"**:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant SS as Self Serve server
(Express, session)
+ participant A0 as Auth0
+ participant GW as lfx-gateway
(Traefik)
+ participant ACS as ACS warden
+ participant V4 as EasyCLA v4
(Lambda)
+
+ SS->>A0: refresh-token exchange
(audience = api-gw.*.platform.linuxfoundation.org)
+ A0-->>SS: access token with
lfx.dev/claims/username + email
+ SS->>GW: /cla-service/v4/... with Bearer token
+ Note over GW: jwks middleware verifies
signature + issuer only
(audience check disabled)
+ GW->>ACS: authorize(username, URL path, HTTP method)
+ alt ACS denies
+ ACS-->>GW: allowed = false
+ GW-->>SS: 403 (request never reaches EasyCLA)
+ else ACS allows
+ ACS-->>GW: allowed + user's scopes
+ GW->>V4: request + X-ACL (base64 scopes),
X-Username, X-Email
+ Note over V4: decode X-ACL (no JWT validation)
match scope type+ID against
project/company IDs in request
+ V4-->>SS: 200, or 403 if scopes do not match resource
+ end
+```
+
+**Layer 1 — lfx-gateway (coarse allow/deny + scope injection)** **[verified]**. All non-public `/cla-service` paths run the `secured` chain (`lfx-gateway/dynamic/services/cla-service.yaml:45-53`):
+
+- JWT check is **signature + issuer only**; audience validation is commented out ("Skip requiring audience for now") — `dynamic/middleware.yaml:35-36`.
+- The ACS authorizer plugin calls ACS warden (`POST /v1/api/warden/subjects/authorize/v2`) with **username, URL path, HTTP method** and **403s at the gateway** if not allowed (`traefik-acs-authorizer-middleware/acs.go:399-401`). A denied request never reaches EasyCLA.
+- On allow, it injects the user's scopes as a base64 `X-ACL` header plus `X-Username`/`X-Email` (`acs.go:410-419`). `X-ACL` is a plain JSON blob — not signed, not a token.
+- **ACS caches warden authorize responses for 30 minutes** (`acs/middleware/cache.go:39,52-54`) — so the gateway's allow/deny decision, and the injected scopes, can be up to 30 minutes stale after a role grant **or revocation**.
+- **Many v4 paths bypass all of this** on a public router — health, `request-individual-signature`, `user-from-token`, `notify-cla-managers`, the designee-check, the cla-group manager list, and more (`cla-service.yaml:4-38`).
+
+**Layer 2 — v4 handlers (fine-grained resource binding)** **[verified]**. v4 does **no JWT validation of its own** on `/v4`: `v2API.LfAuthAuth = lfxAuth.SwaggerAuth` (`cla-backend-go/cmd/server.go:481`) simply base64-decodes `X-ACL` into an `authUser` (`lfx-kit@v0.1.33/auth/handlers.go:76-82`). Handlers then check that the scopes match the **specific project/company SFIDs in the request** — the part the gateway can't do, since ACS only sees the URL. They compare scope **type + ID**; the scope's `Role` field is never consulted (`cla-backend-go/utils/utils_user_auth_lambda.go`; `lfx-kit/auth/user.go:91-127`).
+
+> **Architectural takeaway**: role-specificity lives in **ACS's policy mapping** (which roles produce which scopes for which resource×action), not in EasyCLA code. "Will v4 accept a Self Serve token" is really "will ACS warden authorize this *user* for this *path*" — the same question for every client.
+
+---
+
+## 3. Enforcement map per endpoint group
+
+| Endpoint group | What's checked | Where **[verified]** | Notes |
+|---|---|---|---|
+| Approved List `PUT …/approval-list` | project\|org tree scope from X-ACL; **staff-admin disallowed** | `v2/signatures/handlers.go:96,121` | Plus gateway warden check |
+| CLA manager `POST/DELETE …/cla-manager` | project\|org tree scope; admin disallowed | `v2/cla_manager/handlers.go:64,116` | Write dual-updates signature ACL **and** ACS role (`v2/cla_manager/service.go:242,405`) |
+| CLA manager **designee** `POST` | **Nothing** — "anyone create assign a CLA manager designee...no permissions checks" (code comment) | `v2/cla_manager/handlers.go:158,189` | Invite variants also on the public router |
+| `POST /request-corporate-signature` | project\|org tree scope; admin disallowed | `v2/sign/handlers.go:123` | |
+| `PUT …/ecla-auto-create` | **Signature-ACL membership** (`CurrentUserInACL`), *not* ACS scopes; + persisted sanctions flag | `v2/signatures/handlers.go:1335-1350` | Second source of manager truth |
+| CLA group admin ops (project lens) | project / project-tree scope; **staff-admin allowed** | `v2/cla_groups/handlers.go:476,561,588` | |
+| `GET /signatures/user/{userID}` | No per-user ownership check | confirmed in M1 research (R3) | SS server must bind userID to session |
+| `GET /company/{id}/cla-group/{g}/cla-managers` | **No auth at all** (`security: []`; public router) | `v2/company/handlers.go:162-164`; `swagger/cla.v2.yaml:3829-3836` | Useful zero-auth read path (§5) |
+
+Two consequences worth naming plainly:
+
+1. **Enforcement is uneven.** Designee creation is unauthenticated-by-design; `ecla-auto-create` checks a different store (signature ACL) than Approved List edits (ACS scopes); staff admins can administer CLA groups but *cannot* touch Approved Lists or managers. **SS should mirror v4's decisions rather than re-derive them** — otherwise SS and the backend will disagree at exactly these seams.
+2. **v4 trusts `X-ACL` unconditionally.** No authorizer or API-key requirement exists on the v4 stack itself (`cla-backend-go/serverless.yml`; the Corporate Console BFF's `X-API-KEY` header is required by nothing in this repo). The v4 Lambda has no HTTP/API-Gateway event — it's invoked lambda-to-lambda by the platform gateway (`serverless.yml:334-352`) **[verified]** — so the residual risk is invoke-permission scope, not a public URL: whether any principal beyond the gateway can invoke it with a forged X-ACL is **[inferred/unverified]** — spike 4; a hardening item independent of this program.
+
+---
+
+## 4. Token path: Self Serve already has what it needs
+
+There is no canonical "EasyCLA client token" to imitate — each console authenticates as its own Auth0 client, and the gateway doesn't care:
+
+```mermaid
+flowchart LR
+ subgraph CLIENTS["EasyCLA API clients (all equivalent to the gateway)"]
+ CC["Corporate Console BFF
sends user's Auth0 ID token
(no API audience at all)"]
+ CTC["Contributor Console
own Auth0 SPA client"]
+ SS["Self Serve server
access token for audience
api-gw.*.platform.linuxfoundation.org
(refresh-token exchange, already built)"]
+ end
+ GW["lfx-gateway secured chain
checks signature + issuer ONLY
audience NOT checked
requires claim lfx.dev/claims/username"]
+ CC --> GW
+ CTC --> GW
+ SS --> GW
+ GW -->|"identity-based:
same username = same X-ACL"| V4["EasyCLA v4"]
+```
+
+The verified chain, link by link:
+
+- The Corporate Console's permission strings (`signature_approval_list:update:project|organization:…`) are **not token claims** — its BFF fetches them at runtime from user-service (`GET …/me/permissions`, checks via `POST …/me/permissions/checks`; `lfx-corp-cla-console/backend/src/data/user-api.ts:27-44,160-164`) and calls v4 with the user's raw Auth0 **ID token** (`backend/src/data/cla-api.ts:29-30`) — proof the current system relies on the audience check being disabled. **[verified]**
+- The gateway needs the token to carry `http://lfx.dev/claims/username` (exported to the ACS plugin via headers, `middleware.yaml:37-42`; missing username → 403, `acs.go:328-331`). **[verified]**
+- Auth0 stamps that claim on **access tokens whose audience matches `https://api-gw.(env.)platform.linuxfoundation.org/`** (`auth0-terraform/src/actions/custom_claims.js:387-395`). **[verified]**
+- **Self Serve already mints exactly this token**: `extractApiGatewayToken()` exchanges the session's refresh token for a second, user-scoped access token with audience `API_GW_AUDIENCE` (`lfx-self-serve/apps/lfx-one/src/server/middleware/auth.middleware.ts:230-249`; `.env.example:146`; mechanism `server/utils/refresh-token-exchange.util.ts:20-101`). **[verified]**
+
+**Finding: no token-exchange project is needed — it's already built.** From the gateway's, ACS's, and v4's perspective, a request with SS's token is indistinguishable from the same user arriving via the Corporate Console: same username → same warden answer → same X-ACL → same `authUser`.
+
+**Direction (confirmed at architecture review, 2026-07-20)**: SS sends **access tokens** minted for the api-gw audience — the same access/refresh-token authentication the me/project lenses use — **never ID tokens**. The console's ID-token behavior above is documented as legacy evidence, not a pattern to follow; if the interim cutover requires the gateway/ACS/v4 to support both token types simultaneously, that adjustment is acceptable.
+
+Residual unknowns are operational, not architectural **[inferred, spikes 1–2]**: (a) the SS Auth0 client's grant for the api-gw audience per environment (the exchange currently uses `PCC_AUTH0_*` credentials — `auth.middleware.ts:237-241`); (b) whether warden **allows role-less users** on secured v4 read paths (contributors today mostly ride the *public* router, so this is genuinely untested).
+
+---
+
+## 5. Read path: how SS learns "user X has CLA authority over company Y / CLA group Z"
+
+Why freshness varies — manager assignment is a dual write with asymmetric propagation:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant M as CLA manager
(console or SS)
+ participant V4 as EasyCLA v4
+ participant DDB as DynamoDB
signature ACL
+ participant OS as org-service / ACS
(LFX v1)
+ participant RC as ACS read cache
(~30 min)
+
+ M->>V4: POST .../cla-manager (add manager)
+ V4->>DDB: add username to signature ACL (synchronous)
+ V4->>OS: assign cla-manager role, project|org scope (asynchronous)
+ Note over OS: propagation delay
(consoles poll up to 30x today)
+ OS-->>RC: role visible after propagation + cache expiry
+ Note over M,RC: consequence - "who is a manager" has two truths with different freshness.
UI gating uses the self permission check (same ACS decision the gateway enforces),
the signature-ACL endpoint gives the freshest pending-state signal
+```
+
+Candidate read paths, assessed:
+
+| Candidate | Auth needed | Latency | Staleness | Verdict |
+|---|---|---|---|---|
+| `GET /v4/company/{id}/cla-group/{g}/cla-managers` | **None** (public router, `security: []`) | 1 DynamoDB read | Signature ACL — updated synchronously; same store `ecla-auto-create` enforces | Display data for manager lists; freshest **post-assignment signal** for pending-state UX |
+| `GET /v4/company/{id}/project/{sfid}/cla-managers` | Org scope on the company (`v2/company/handlers.go:144`) | gateway + ACS + DDB | same, but gateway decision cached 30 min | Use where org context established |
+| user-service `POST /me/permissions/checks` (self permission check) | User's api-gw token | ACS resolution | ~30-min cache (`acs/middleware/cache.go:39,52-54`) — **matches enforcement staleness**, so UI and API agree | **Primary UI-gating signal** (architecture-review guidance): pre-checks the same ACS decision the gateway enforces |
+| ACS rolescopes APIs (`acs/userrole/transport_http.go:38-43,106-111`) | Service-level (M2M) | Postgres direct | 30-min cache on some routes | Fallback for admin/reporting views |
+| `cla-{stage}-user-permissions` DynamoDB table | — | — | — | **Reject**: feeds only the v3 OAuth authorizer (`cla-backend-go/user/repository_dynamo.go:114`, `auth/authorizer.go:148`); not v4 truth |
+| Optimistic call-through: attempt the v4 call, treat 403 as "no authority" | User's api-gw token | one round trip | Matches enforcement (including its 30-min gateway cache) | Backstop: always handle 403 gracefully — enforcement is the final word |
+
+**Recommendation (updated after architecture review, 2026-07-20)**: gate SS UI via the user's **self permission check** (`POST user-service/v1/me/permissions/checks`) — it pre-checks the same ACS decision the gateway enforces, so UI gating and API enforcement agree by construction, including cache behavior. Always handle v4/gateway 403s gracefully rather than trying to out-predict enforcement. The public cla-managers endpoint remains the data source for *rendering* manager lists and the freshest signal for post-assignment "pending" UX (it reflects the synchronous signature-ACL write before ACS propagation and cache expiry). Do not build a permission-string evaluator in SS beyond these checks.
+
+---
+
+## 6. Options assessment (confirming/overturning milestone 04)
+
+| | A. Bridge | B. Model CLA in OpenFGA now | C. Org-admin = CLA manager |
+|---|---|---|---|
+| Verdict | **Confirmed — recommended** | Rejected for M3–M5 (revisit at M6) | Rejected — hard technical blocker |
+| Cost vs. milestone-04 estimate | **Lower** | Higher (two upstream truths to sync) | Higher (requires rewriting v4 enforcement) |
+
+**A. Bridge — confirmed, cheaper than assumed.** The milestone-04 concern "v4 might not accept SS tokens without gateway changes" is resolved negatively: no gateway change, no EasyCLA auth change, no new token infrastructure (§4). Residual costs: the spikes' outcomes (possibly a small ACS policy addition), and inheriting today's failure modes unchanged — async ACS assignment (server-side retries stay in SS), the documented **one-company-at-a-time role limitation** (the bridge surfaces but cannot fix it), and the enforcement unevenness in §3, which SS must mirror, not mask.
+
+**B. OpenFGA copy now — rejected, evidence strengthened.** The copy would be non-enforcing (v4 checks X-ACL/ACS and signature ACLs; no FGA CLA types exist — `lfx-v2-fga-sync/docs/fga-catalog.md`), and there are *two* upstream truths to sync (§1), doubling the divergence surface. ACS's 30-min cache already causes UI-vs-enforcement drift today; adding a third eventually-consistent copy on top of an async assignment pipeline is the "SS says I can, EasyCLA says I can't" scenario. Model CLA in FGA at M6, when enforcement itself moves.
+
+**C. Org-admin = CLA manager — rejected.** Beyond the legal/product semantics change (who may alter Approved Lists and sign CCLAs): v4's write paths check **project|organization tuple scopes** with staff-admin explicitly disallowed (§3). `b2b_org#writer` has no project dimension, so the mapping either grants Approved List control per-company-across-all-projects (a semantics change) or requires rewriting v4 enforcement — which is M6, not a UI milestone.
+
+**Net: the role difference is a contained adapter in the SS `cla` server module**, consistent with the program strategy (strangler with v4 as enforcement core until M6).
+
+### 6.1 UX consistency: how the bridge differs from SS-native permission management
+
+Concern raised in review: SS users may expect to manage permissions "the same way" everywhere, while CLA authority is managed through ACS. Assessment: **the storage split is invisible to users, but the *behavioral* differences are real and need explicit M4 design.**
+
+Context **[verified]**: SS permission management is already federated per domain — org admins via member-service (`lfx-self-serve/apps/lfx-one/src/server/services/org-lens-access.service.ts:60-102`), committee seats via committee-service, key contacts via member-service — each publishing OpenFGA tuples over NATS (`lfx-v2-fga-sync/docs/fga-catalog.md`; grants effective near-instantly). There is no unified permissions console. EasyCLA-in-SS follows the same shape (SS UI → domain service owns the grant); ACS is plumbing users never see, like member-service. What *does* leak to users:
+
+| Difference | SS-native modules | CLA module (bridged) | M4 design consequence |
+|---|---|---|---|
+| Grant latency | Near-instant (sync write + NATS) | **Async** (org-service → ACS; console polls 30×; 30-min warden cache — affects revocations too) | Honest **pending states** — no other SS module needs them |
+| Role model | `writer`/`auditor` per org | `cla-manager`/`signatory`/`designee` per **company × project/CLA group** | Own screens; can't reuse the Access tab |
+| org-admin ≠ CLA-manager | Adding a `writer` grants org-wide abilities | Grants **no** CLA authority | Explicit UX copy in both places — the likeliest user surprise |
+| People views | Access tab lists org roles | CLA managers invisible there | Decide: surface CLA roles read-only in People (cheap — the cla-managers endpoint is public, §5) or keep them in the CLA module |
+| Eligibility/limits | none comparable | LF SSO required for new managers; one-company-at-a-time role; staff-admin disallowed on CLA writes | Support docs + error copy |
+| Support runbook | SS → member-service → FGA | SS → v4 → org-service → ACS | Feeds M4's exit criterion "role-bridge behavior documented for support" |
+
+Mitigating fact: all of this is the **status quo** — the Corporate Console behaves this way today. The new risk is contrast, not regression: SS-native modules set a faster baseline that makes the bridged CLA module look worse unless its pending/error states are deliberately designed.
+
+---
+
+## 7. Spike list (dev environment, curl-level)
+
+1. **SS token → secured v4 read.** Exchange a dev SS session's refresh token for the api-gw audience, then `GET /cla-service/v4/company/{id}/project/{sfid}/cla-managers` as a known CLA manager. **Expected**: 200 — decodes the whole chain (claims → gateway → warden → X-ACL → v4 scope check). A gateway 403 means an ACS policy/grant gap; capture the warden request/response.
+2. **Role-less user → M1 read path.** Same exchange for a user with no ACS roles; `GET /cla-service/v4/signatures/user/{their-userID}`. **Outcome unknown — this is the decision point**: 200 ⇒ M1 uses user tokens as designed; 403 ⇒ file the ACS policy addition or fall back to M2M + server-side subject binding.
+3. **Write path end-to-end.** As a dev CLA manager with an SS-minted token, `PUT /cla-service/v4/signatures/project/{projectSFID}/company/{companyID}/clagroup/{claGroupID}/approval-list` (note the path segment is `clagroup`, not `claGroupID`) adding a test email → expect 200. Repeat as a non-manager → expect 403 **at the gateway** (not v4), confirming where denial surfaces for UX copy.
+4. **X-ACL forgery check (hardening).** The v4 Lambda (`cla-easycla-api-v4-lambda`, us-east-2) has **no HTTP/API-Gateway event** — it is invoked lambda-to-lambda by the platform gateway (`cla-backend-go/serverless.yml:334-352`). So this is an **IAM review, not a curl**: audit who holds `lambda:InvokeFunction` on that function, and confirm whether the gateway's invoke path signs/derives `X-ACL` in a way a direct invoker could not forge (v4 trusts `X-ACL` unconditionally — §3). If any principal beyond the gateway can invoke it with an arbitrary payload, file a security issue (independent of this program).
+5. **Designee propagation clock.** `POST …/cla-manager-designee` for a test user, then poll user-service `me/permissions/checks` **and** the cla-managers endpoint, timestamping when each turns positive. Include a revocation timing check (the 30-min warden cache means removals may also linger). The numbers calibrate SS's retry budget and "pending" UX for M3/M4.
+
+---
+
+## Appendix — verification ledger
+
+**Verified in code (2026-07-15, re-checked 2026-07-20)**
+Gateway routing/middleware: `lfx-gateway/dynamic/services/cla-service.yaml:3-53`, `dynamic/middleware.yaml:21-70` · ACS plugin deny + header injection: `traefik-acs-authorizer-middleware/acs.go:328-331,378,399-419` · ACS warden/permission-check 30-min response cache: `acs/middleware/cache.go:39,52-54` · v4 auth wiring: `cla-backend-go/cmd/server.go:480-481` · X-ACL decode: `lfx-kit@v0.1.33/auth/handlers.go:55-82` · scope-not-role checks: `cla-backend-go/utils/utils_user_auth_lambda.go:33-268`, `lfx-kit/auth/user.go:91-269` · per-endpoint checks as tabled in §3 · dual manager bookkeeping: `v2/cla_manager/service.go:242,405` (+ auto-assigned `contact` role, `service.go:417-427`) · Auth0 claim gating by audience: `auth0-terraform/src/actions/custom_claims.js:387-395` · SS secondary token: `lfx-self-serve/apps/lfx-one/src/server/middleware/auth.middleware.ts:230-249`, `server/utils/refresh-token-exchange.util.ts:20-101`, `apps/lfx-one/.env.example:146` · console BFF token/permissions: `lfx-corp-cla-console/backend/src/data/cla-api.ts:23-32`, `backend/src/data/user-api.ts:27-44,160-164` · ACS roles hardcoded + read APIs: `acs/userrole/repository.go:101-102`, `acs/userrole/transport_http.go:38-43,106-111` · v3-only permissions table: `cla-backend-go/user/repository_dynamo.go:114`, `auth/authorizer.go:143-166` · SS-native grant paths: `org-lens-access.service.ts:60-102`, `lfx-v2-fga-sync/docs/fga-catalog.md` · v4 Lambda has no HTTP event (invoked lambda-to-lambda): `cla-backend-go/serverless.yml:334-352` · spike endpoint paths/methods/routers: `swagger/cla.v2.yaml:2407,2518,3797`, `lfx-gateway/dynamic/services/cla-service.yaml:4-53`.
+
+**Inferred (flagged in text, each covered by a spike)**
+Warden's answer for role-less users on secured v4 read paths (spike 2) · SS Auth0 client's api-gw audience grant per environment (spike 1) · who besides the gateway can invoke the v4 Lambda (spike 4) · exact end-to-end staleness of the designee flow (spike 5) · the console BFF's `X-API-KEY` was not found to be enforced anywhere in `cla-backend-go` — treated as vestigial pending spike 4.
diff --git a/docs/easycla-ss-migration/spike-runbook.md b/docs/easycla-ss-migration/spike-runbook.md
new file mode 100644
index 000000000..14728377b
--- /dev/null
+++ b/docs/easycla-ss-migration/spike-runbook.md
@@ -0,0 +1,83 @@
+
+
+# Spike Runbook — Spikes 1 & 2 (dev)
+
+Ready-to-run steps for the two decision-gating spikes in [role-mapping-feasibility.md §7](role-mapping-feasibility.md). Both run against the **dev** environment. Goal: confirm a Self-Serve-minted user token reaches EasyCLA v4 through lfx-gateway, and find out whether a **role-less** user is allowed through.
+
+## What you need
+
+- The **dev api-gw audience**: `https://api-gw.dev.platform.linuxfoundation.org/` (`lfx-self-serve/apps/lfx-one/.env.example:146`).
+- The **Auth0 issuer, client_id, client_secret** the SS server uses for the exchange — the `PCC_AUTH0_*` values in the dev SS deployment's env (`.env.example:9,11`; real values in the dev secret store / running config, not the repo).
+- A **user refresh token** for each test user (see step 1 for options). Requires the `offline_access` scope.
+- Two dev test users: **(A)** a known CLA manager for some company × CLA group; **(B)** a user with **no** ACS CLA role.
+- Gateway base URL (dev): `https://api-gw.dev.platform.linuxfoundation.org` — the `/cla-service/v4/*` prefix routes to the EasyCLA v4 Lambda.
+
+## Step 1 — get a user access token for the api-gw audience
+
+This reproduces what SS's `extractApiGatewayToken()` does (`auth.middleware.ts:230-249` → `refresh-token-exchange.util.ts:104-120`): a `refresh_token` grant asking for the api-gw audience.
+
+```bash
+ISSUER="https://" # PCC_AUTH0_ISSUER_BASE_URL (dev)
+CLIENT_ID="" # PCC_AUTH0_CLIENT_ID (dev)
+CLIENT_SECRET="" # PCC_AUTH0_CLIENT_SECRET (dev)
+AUDIENCE="https://api-gw.dev.platform.linuxfoundation.org/"
+REFRESH_TOKEN="" # swap for user B in the role-less run
+
+TOKEN=$(curl -s -X POST "$ISSUER/oauth/token" \
+ -H 'Content-Type: application/x-www-form-urlencoded' \
+ --data-urlencode "grant_type=refresh_token" \
+ --data-urlencode "refresh_token=$REFRESH_TOKEN" \
+ --data-urlencode "client_id=$CLIENT_ID" \
+ --data-urlencode "client_secret=$CLIENT_SECRET" \
+ --data-urlencode "audience=$AUDIENCE" \
+ | jq -r .access_token)
+
+echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq . # inspect claims
+```
+
+**Checkpoint (this is spike 1's core prerequisite):** the decoded token must contain `http://lfx.dev/claims/username`. If it's present, Auth0 is granting the api-gw audience to this client and stamping the claim the gateway needs — the prerequisite is met (spike 1 is only "passed" once the secured `cla-managers` call below returns `200`; see `role-mapping-feasibility.md` line 227). If the exchange returns an error (e.g. `invalid_grant`, `access_denied`, or unauthorized audience), spike 1 has found the gap: the SS Auth0 client isn't authorized for that audience in dev — that's an Auth0 client-grant config item, not a code change.
+
+> Getting a refresh token: log into dev SS and read it from the session store (`req.appSession.refresh_token`) via a controlled inspection, or run a one-off authorization-code+PKCE login against the dev client with `scope=openid offline_access`. Either way the token must carry `offline_access`. The refresh token (and the client secret above) are reusable credentials — do not log or persist them, and revoke/discard the token once the spike is done.
+
+## Step 2 — call a secured v4 endpoint
+
+### Spike 1 (user A = CLA manager): expect **200**
+
+```bash
+COMPANY_ID=""
+PROJECT_SFID="" # a project A manages CLAs for
+
+curl -s -o /dev/null -w "%{http_code}\n" \
+ "https://api-gw.dev.platform.linuxfoundation.org/cla-service/v4/company/$COMPANY_ID/project/$PROJECT_SFID/cla-managers" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+- **200** → the whole chain works: SS token → gateway (issuer check) → ACS warden allow → X-ACL injected → v4 scope check passes. Spikes 1's happy path confirmed.
+- **403** → capture the body. If the gateway rejects (`User does not have access to resource or path ...`), it's an ACS warden/policy result. If the body is a v4 payload, the scopes didn't match the resource. Note which.
+
+### Spike 2 (user B = no CLA role): the decision point
+
+Re-run step 1 with user B's refresh token, then hit the M1 read endpoint. First resolve B's EasyCLA userID (the SS server does this via identity resolution; for the spike, use a known dev userID for B):
+
+```bash
+USER_ID=""
+
+# $TOKEN here is user B's token — re-run Step 1 with B's refresh token first
+curl -s -o /dev/null -w "%{http_code}\n" \
+ "https://api-gw.dev.platform.linuxfoundation.org/cla-service/v4/signatures/user/$USER_ID" \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+- **200** → **role-less users pass the secured router.** M1/M3 use user tokens exactly as proposed (P3); no fallback needed. Best outcome.
+- **403 at the gateway** → warden denies users with no CLA role on this path. M1 then needs **either** a small ACS policy entry admitting authenticated users to these read paths, **or** the M2M fallback (SS server calls with a service token and binds the session userID itself — still Option A). Capture the warden response so we can size the policy change.
+
+## Recording results
+
+For each call log: user, endpoint, HTTP status, and (on 403) the response body and whether it looks gateway-issued or v4-issued. Those four fields answer both spikes and feed the P3 decision.
+
+## Notes / gotchas
+
+- **`clagroup` vs `claGroupID`**: unrelated to these spikes, but if you also try the write path (spike 3), the approval-list segment is `.../clagroup/{claGroupID}/approval-list` — lowercase `clagroup`.
+- A **404** on the cla-managers call usually means wrong company-internal-id vs SFID — the path wants the internal company ID, not the Salesforce ID.
+- The gateway only reads `http://lfx.dev/claims/username` from the token; a token missing that claim 403s at the ACS plugin regardless of audience, so the step-1 checkpoint matters before you debug step 2.