diff --git a/.github/workflows/ci-scripts-test.yml b/.github/workflows/ci-scripts-test.yml index 0b7f62e2733f..d9ae330aa2a0 100644 --- a/.github/workflows/ci-scripts-test.yml +++ b/.github/workflows/ci-scripts-test.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "scripts/ci/**" + - "design/dedicated-integrations/**" - ".github/workflows/ci-scripts-test.yml" - "pyproject.toml" - "src/bundles/*/pyproject.toml" @@ -45,7 +46,7 @@ jobs: python-version: "3.13" - name: Install dependencies - run: pip install orjson packaging pytest requests + run: pip install jsonschema orjson packaging pytest requests - name: Run CI script tests run: python -m pytest scripts/ci/ -v diff --git a/design/dedicated-integrations/README.md b/design/dedicated-integrations/README.md new file mode 100644 index 000000000000..abe1d534763c --- /dev/null +++ b/design/dedicated-integrations/README.md @@ -0,0 +1,183 @@ +# Dedicated Integrations (1.13): discovery gate records + +Status: gate artifacts complete on 2026-09-01; the gate remains open pending role-owner sign-offs. Every decision +record is `accepted` by the release owner and the structural checker passes. Gate-close mode +(`check_capability_matrices.py --require-accepted`) intentionally remains red until every declared owner's Name, +Date, and PR cells are complete in both the aggregate table and each record. Wave 1 = Google 5, Microsoft 8, Slack +7 actions. +Jira: LE-2398 "Dedicated Integrations", ticket INT-1. +Last updated: 2026-09-02 (Desktop OAuth registration ownership decision added) + +This directory holds the outputs of INT-1, the discovery gate that every other Dedicated Integrations ticket +(INT-2 through INT-14) blocks on. It freezes what 1.13 ships before any provider code is written. The records are +frozen at gate close; from INT-3 onward the bundle-owned capability manifest is the runtime source of truth and +these files are the historical record the checker keeps guarding. + +## Exit criteria and where each one lives + +| # | Exit criterion (from INT-1) | Artifact | Machine check | Status | +|---|---|---|---|---| +| 1 | Three approved matrices, at most 8 actions each | `matrices/google.json`, `matrices/microsoft.json`, `matrices/slack.json` | `check_capability_matrices.py`: JSON Schema, included-action cap, required fields, enums | Google 5 included (Gmail search excluded), Microsoft 8 included, Slack 7 included (all Web API) | +| 2 | Every scope classified; every restricted scope has a written decision | scope entries in the matrices; `decisions/google-restricted-scopes.md` | classification present and sourced; every scope on an included action tagged `required`, `optional`, or `alternative`; conditional rows carry a structured predicate naming a real action input; at least one scope is required; restricted scopes need a `restricted_scope_decisions` entry pointing at an existing record | accepted: avoid on the hosted app; Gmail search excluded, Drive on drive.file | +| 3 | Substrate decision per provider with the server's GA status | `decisions/substrate-google.md`, `decisions/substrate-microsoft.md`, `decisions/substrate-slack.md`; `substrate_decision` in each matrix | included actions must use a chosen substrate; non-GA MCP rows cannot be high confidence | accepted: Google sdk, Microsoft rest, Slack rest; Slack MCP deferred to 1.14 pending exact tool evidence | +| 4 | INT-2 connection-resolution contract signed off by lfx, langflow-base, Enterprise owners | `connection-contract.md` | sign-off coverage: every declared owner role has a row in the sign-off table below that lists the record | drafted 2026-09-01; 12 sections, owner questions in section 12 | +| 5 | Frontend surface list | `frontend-surfaces.md` | none | drafted 2026-09-01; 14 extend + 9 new, including the operator governance surface; MVP/defer split | +| 6 | Trigger/webhook track recorded as deferred | `triggers-deferred.md` | none | governing-plan findings folded in; provider transport and delivery discovery remains deferred | +| 7 | Re-issued estimate | `estimate.md` | none | re-issued 2026-09-01 and amended 2026-09-02: 48.75 engineer-weeks under the confirmed decisions | +| + | KB OAuth connector adoption decision (added by the release owner) | `decisions/kb-oauth-connector-adoption.md` | none | accepted: adopt in 1.13 (release owner overrode the gate's defer recommendation); +1.5 engineer-weeks | +| + | Palette naming next to Composio components (added by the release owner) | `decisions/palette-naming.md` | none | accepted: 'Product: Verb Object' names, new Microsoft 365 and Slack groups, Composio unchanged | +| + | Desktop OAuth registration ownership (added by the release owner, 2026-09-02) | `decisions/desktop-oauth-ownership.md` | none | accepted: Langflow-owned public clients on Desktop, customer-owned registrations remain the override; +0.25 engineer-weeks | + +Gate close means: every row above is done, every record under `decisions/` is `Status: accepted` (the checker walks +them all, not only the ones a matrix references), every declared owner has completed both sign-off tables, and +`uv run python scripts/ci/check_capability_matrices.py --require-accepted` exits 0. + +## Sign-off + +Acceptance rule: `Status: accepted` on a record means the release owner accepted it; it is necessary but not +sufficient for gate close. Every other role a record names in its `Owners (sign-off roles):` line signs off in PR review +by approving and filling in its row below and in the record's own sign-off table. The checker +(`validate_sign_offs`) fails when a record names a role that has no row here, when that row does not list the +record, or when the record's own sign-off table is missing a declared role. In `--require-accepted` mode it also +fails blank or invalid Name, Date, and PR cells. Role placeholders remain until the release owner assigns names. + +| Role | Signs off on | Name | Date | PR | +|---|---|---|---|---| +| lfx owner | `connection-contract.md`, `decisions/substrate-google.md`, `decisions/substrate-microsoft.md`, `decisions/substrate-slack.md`, `decisions/kb-oauth-connector-adoption.md` | | | | +| langflow-base owner | `connection-contract.md`, `decisions/substrate-google.md`, `decisions/substrate-microsoft.md`, `decisions/substrate-slack.md`, `decisions/google-restricted-scopes.md`, `decisions/kb-oauth-connector-adoption.md`, `decisions/desktop-oauth-ownership.md` | | | | +| Enterprise owner | `connection-contract.md`, `decisions/substrate-google.md`, `decisions/substrate-microsoft.md`, `decisions/substrate-slack.md`, `decisions/google-restricted-scopes.md` | | | | +| frontend owner | `connection-contract.md` (section 12.d), `frontend-surfaces.md`, `decisions/palette-naming.md` | | | | +| hosted-app owner | `decisions/google-restricted-scopes.md`, `decisions/substrate-google.md`, `decisions/substrate-microsoft.md`, `decisions/substrate-slack.md`, `decisions/desktop-oauth-ownership.md`, hosted and desktop rows of every matrix | | | | +| product owner | `decisions/palette-naming.md` | | | | +| platform owner | `triggers-deferred.md` | | | | +| release owner | every record in this directory, all matrices, `estimate.md`, gate close | Eric Hare | 2026-09-01 | #14906 | + +## Running the checker + +```bash +uv run python scripts/ci/check_capability_matrices.py +``` + +```bash +uv run python scripts/ci/check_capability_matrices.py --require-accepted +``` + +```bash +uv run pytest scripts/ci/test_capability_matrices.py +``` + +The checker validates every matrix against `schema/capability_matrix.schema.json` (Draft 2020-12 through +`jsonschema`, which the `CI Scripts Tests` workflow installs and the workspace environment already carries) before +applying the gate rules a schema cannot express, and runs on any change under this directory. Besides the matrices it +validates sign-off coverage: every `Owners (sign-off roles):` line under this directory must be +mirrored by the sign-off table above and by the record's own table. + +## Directory map + +```text +README.md this file +schema/capability_matrix.schema.json JSON Schema for one provider matrix; its enums are asserted equal to the checker's +matrices/.json one capability matrix per wave-1 provider +decisions/TEMPLATE.md decision-record template; the checker parses the Status line and requires a "## Decision" heading +decisions/substrate-*.md official MCP vs SDK/REST per provider +decisions/google-restricted-scopes.md CASA-or-avoid for the Langflow-owned hosted Google app, one subsection per restricted scope +decisions/kb-oauth-connector-adoption.md (Phase 6) +decisions/palette-naming.md (Phase 6) +decisions/desktop-oauth-ownership.md Desktop registrations: Langflow-owned public clients by default, customer-owned override (2026-09-02) +connection-contract.md (Phase 5) INT-2 design for sign-off +frontend-surfaces.md (Phase 7) +triggers-deferred.md deferred track with re-open trigger +estimate.md (Phase 8) +``` + +## Matrix field glossary + +Every claim-bearing value carries a `source` id that resolves in the matrix's top-level `sources` registry, and every +source carries the URL, a title, a kind, and the `verified_on` date it was last read. Verification for this gate is +documentation-only: no live tenants were exercised. Provider documentation remains authoritative for availability, +auth, and policy. A dated authenticated `mcp_tools_list` capture is authoritative for the exact tool identifiers and +schemas it enumerates and must be paired with provider documentation before an MCP substrate is selected. + +Top level: `provider`, `display_name`, `bundle` (extension id, bundle name, distribution), `wave`, +`max_included_actions` (at most 8), `verified_on`, `oauth_app_owner_by_context` (who owns the OAuth registration in +each of `hosted`, `self_managed`, `desktop`, `headless`), `oauth_client_type_by_context` (`confidential`, `public`, +or externally provisioned per context), `substrate_decision` (`chosen` substrates and the decision record), +`restricted_scope_decisions` (one entry per restricted scope any included or deferred action carries), `sources`, +`verification_programs` (external verification or licensing programs an action depends on), `actions`. + +Per action: + +| Field | Meaning | +|---|---| +| `action_id` | Stable Langflow-owned id, `..`; becomes the manifest capability id in INT-3 | +| `component_class` | Proposed `*Component` class name; bare names must stay unique across bundles | +| `decision` | `include` (ships in wave 1), `exclude` (will not ship), `defer` (undecided or later wave) | +| `rationale`, `confidence`, `open_questions` | Why; how sure; what must be verified. `low` confidence requires open questions | +| `schema` | Proposed inputs and outputs, with the provider API page they map to. Required for included actions | +| `auth_mode` | How credentials are obtained: authorization code, client credentials, device code, service account, domain-wide delegation, bot install, API key | +| `identity` | Who executes: `user_delegated` (connected user), `bot` (app identity), `service` (service account or application permission) | +| `scopes[]` | Each with `classification`, the provider's own term in `provider_classification`, and a source. On included actions each scope also carries a `role`: `required` (always requested; becomes the manifest's `required_scopes`), `optional`, or `alternative`. Conditional roles become manifest `conditional_scopes` and carry a structured `condition` (`input_present` or `input_truthy`) that must name a declared action input. At least one scope is required | +| `consent` | `user`, `admin`, or `both`; with notes and a source. Required for included actions | +| `reach` | Effective resource and tenant reach. Required for included actions | +| `deployment_contexts` | Map of context to callback mechanism: `server_redirect`, `loopback_redirect`, `device_code`, `app_install_redirect`, `manual_token`, `none`. A context that is absent is unsupported | +| `refresh`, `revocation` | Token lifetime and revocation behavior with sources. Required for included actions | +| `substrate`, `substrate_ga_status` | `sdk`, `rest`, or `mcp`, and the provider server's maturity | +| `rate_limit` | Summary with a service-specific source and its own confidence. Required for included actions | +| `verification_dependencies` | Ids from `verification_programs` this action depends on | + +### Scope classification mapping + +The `classification` enum is Google's vocabulary applied to every provider so the exit criterion "every scope is +classified as non-sensitive, sensitive, or restricted" is checkable uniformly. The provider's own term is kept in +`provider_classification` so the mapping is auditable. + +| Provider | `non_sensitive` | `sensitive` | `restricted` | +|---|---|---|---| +| Google | Google's non-sensitive list (for example `drive.file`) | Google's sensitive list (Calendar scopes, `gmail.send`, to verify) | Google's restricted list (most Gmail scopes, `drive.readonly`, to verify); triggers CASA for the requesting app | +| Microsoft Graph | Delegated permission without admin consent | Delegated permission that requires admin consent | Permission that needs Microsoft protected-API approval (for example application-permission Teams message reads) | +| Slack | Default scopes | Content-reading scopes (`search:read`, `*:history`, `canvases:read`) | `admin.*` and Discovery API scopes | + +### Reference row shape + +The shape below is illustrative; the authoritative rows live in `matrices/`. Values marked `...` are filled from a +cited source in Phase 1 or 2. + +```json +{ + "action_id": "google.gmail.send", + "display_name": "Gmail: Send Email", + "component_class": "GmailSendComponent", + "decision": "include", + "rationale": "...", + "confidence": "high", + "schema": { "inputs": [ { "name": "to", "type": "list[str]", "required": true } ], "outputs": [ { "name": "message", "type": "Data" } ], "source": "gmail-messages-send" }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "scopes": [ { "scope": "https://www.googleapis.com/auth/gmail.send", "classification": "sensitive", "provider_classification": "...", "source": "gmail-scopes", "role": "required" } ], + "consent": "user", + "consent_source": "...", + "reach": { "resource": "...", "tenant": "...", "source": "..." }, + "deployment_contexts": { "hosted": "server_redirect", "self_managed": "server_redirect", "desktop": "loopback_redirect", "headless": "manual_token" }, + "refresh": { "behavior": "...", "source": "..." }, + "revocation": { "behavior": "...", "source": "..." }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_source": "...", + "rate_limit": { "summary": "...", "source": "...", "confidence": "high" }, + "verification_dependencies": ["google-oauth-app-verification"] +} +``` + +## Phases + +| Phase | Deliverables | Needs a decision from the release owner | +|---|---|---| +| 0 | this scaffold, checker, seed rows | no | +| 1 | `matrices/google.json` fully sourced (done 2026-09-01) | no | +| 2 | `matrices/microsoft.json`, `matrices/slack.json` fully sourced (done 2026-09-01) | no | +| 3 | substrate decisions to `proposed` (drafted 2026-09-01) | yes: confirm Google sdk, Microsoft rest, Slack rest for 1.13 | +| 4 | `google-restricted-scopes.md` (drafted 2026-09-01, recommends avoid); rows flip on acceptance | yes: CASA or avoid | +| 5 | `connection-contract.md` (drafted 2026-09-01) | review by lfx, langflow-base, Enterprise owners | +| 6 | KB connector and palette naming decisions (drafted 2026-09-01) | yes | +| 7 | `frontend-surfaces.md` (drafted 2026-09-01) | no | +| 8 | `estimate.md` re-issued; all records `accepted`; owner sign-offs in PR review; `--require-accepted` turns green only after signatures are complete | estimate done; pending sign-offs | diff --git a/design/dedicated-integrations/connection-contract.md b/design/dedicated-integrations/connection-contract.md new file mode 100644 index 000000000000..173c68e95e2b --- /dev/null +++ b/design/dedicated-integrations/connection-contract.md @@ -0,0 +1,414 @@ +# Connection contract: provider-neutral connection reference and credential resolution (INT-2 design) + +Status: draft +Decision ID: connection-contract +Applies to: INT-2 (lfx), with the langflow-base obligations INT-4 and INT-5 must meet and the Enterprise seams +Owners (sign-off roles): lfx owner, langflow-base owner, Enterprise owner, frontend owner +Last verified: 2026-09-01 +Last amended: 2026-09-03 (INT-2 implementation review) + +This document is the INT-2 design that the discovery gate asks the lfx, langflow-base, and Enterprise owners to sign +off before INT-2 is built. Each section states the recommended decision, why, and what was rejected. Section 12 +lists the questions each owner answers in review. File and line references were verified against `release-1.12.0` +on 2026-09-01. + +## 0. Scope and invariants + +INT-2 ships provider-neutral contracts in `lfx` and the headless implementation. It does not ship tables, OAuth +callbacks, encryption, refresh coordination, or UI; those are INT-4 and INT-5 in langflow-base and INT-8 in the +frontend, and this document names what they must provide. + +Glossary: a **connection** is a stored credential owned by a user or by the instance; a **connection reference** +(handle) is what a flow stores; the **execution principal** is whose identity a run executes as and the **dependency +principal** is whose credentials it may resolve (both from `scripts/ci/execution_principal_matrix.json`); a +**lease** is the in-process object a component holds to obtain a short-lived token. + +Security invariants, each mapped to a test in section 11: + +1. Components never see refresh tokens. +2. Credentials never enter `graph.context`, `Graph.__getstate__` (`src/lfx/src/lfx/graph/graph/base.py:1571`), + background job payloads (`services/background_execution/service.py::submit`), trace inputs, telemetry payloads, + or error messages. +3. Refresh happens only inside the host worker that needs the token, single-flight across workers. +4. User connections resolve only for actor-or-explicit-share dependency principals; anonymous principals never + resolve user connections. +5. Every new route and execution seam is classified in `scripts/ci/authz_endpoint_matrix.json` and + `scripts/ci/execution_principal_matrix.json`. + +Corrections to the discovery brief, so reviewers are not surprised: the existing `ConnectionInput` is at +`src/lfx/src/lfx/inputs/inputs.py:843` and is Composio's connect widget (used only by +`src/bundles/lfx-bundles/src/lfx_bundles/composio/composio_api.py`); neither `ConnectionInput` nor `AuthInput` is +listed in `BUNDLE_API.md` although Composio imports `ConnectionInput` from `lfx.io`; `IN_SCOPE_PATHS` in +`scripts/migrate/check_bundle_api_changelog.py:43` covers only `lfx/extension/*`, so `lfx.io` and `lfx.inputs` are +not changelog-gated today; no `ExecutionPrincipal` type exists, only `AuthorizationPrincipal(actor_type, actor_id, +user_id)` at `src/lfx/src/lfx/services/authorization/base.py:58` plus `Graph.user_id`, `end_user_id`, +`tracing_user_id`; the `observability.execution_protocol` ContextVar is telemetry-only and must not be used for +authorization. + +## 1. Connection reference shape + +**Decision: a new parallel channel for semantics, with a variable-compatible encoding for headless transport.** + +- Flow JSON stores a string handle `/`, for example `google_workspace/work`. `provider_id` + reuses `_PROVIDER_ID_RE` from `src/lfx/src/lfx/extension/manifest.py`; `name` is `[a-z0-9]+(_[a-z0-9]+)*`, at most + 64 characters: lowercase so the uppercased env form is invertible, no hyphen so `-` and `_` cannot collide, no + doubled underscore so the `__` separator stays unambiguous. Parsed form is a frozen Pydantic + `ConnectionRef(provider, name)` with `parse()`, `to_handle()`, `env_key()`. +- The handle is owner-kind-neutral. Resolution tries the dependency principal's user connection with that provider + and name, then an instance connection with the same name only if host policy allows the fallback (langflow-base + default: only when the instance connection is flagged referenceable; question 12.b.1). +- Not `load_from_db`. The DB path in `src/lfx/src/lfx/interface/initialize/loading.py:310-364` calls `get_variable` + and raises on a missing variable unless env fallback is on; it returns a bare `str | SecretStr` that cannot carry + expiry, granted scopes, or account identity; `apply_global_variable_defaults` + (`api/v1/global_variable_defaults.py:107`) would try to bind `default_fields` onto it; and `is_valid_env_var_name` + (`src/lfx/src/lfx/cli/validation/_env_validation.py:30`) rejects `/`. +- Headless transport reuses the flat map unchanged: `ConnectionRef.env_key()` is + `LF_CONNECTION____`. Provider ASCII alphanumerics are uppercased and each allowed punctuation + character is escaped as underscore plus its two-digit ASCII hex value (`.` -> `_2E`, `-` -> `_2D`, `_` -> + `_5F`); the connection name is uppercased unchanged. Thus `a.b/work`, `a-b/work`, and `a_b/work` map to distinct + valid environment names without a cross-manifest registry check, while the double-underscore separator still + keeps `google_workspace/work` distinct from `google/workspace_work`. Env keys are only ever derived from handles + and never parsed back into them. It is a valid env name, so + `VariableService.get_variable` (`src/lfx/src/lfx/services/variable/service.py:63-110`) already resolves it through + the five-step order including the `x-langflow-global-var-*` alias and `no_env_fallback`. +- Export and import: handles are portable, non-secret text. `strip_secret_field_values_in_place` + (`src/backend/base/langflow/utils/flow_secrets.py:361`) gains a `connection_references: set[str] | None` kwarg + mirroring `variable_references` and preserves `connection_ref`-typed values; the frontend + `removeGlobalVariableFromComponents` (`src/frontend/src/utils/reactflowUtils.ts:2140`) must not touch them. No + account identity is ever stored in flow JSON. +- `required_connections`: the deployment artifact manifest adds `required_connections: [{provider, name, scopes}]` + per flow and aggregated, beside `required_variables` + (`src/backend/base/langflow/services/deployment_artifacts/builder.py:347`); scopes come from the input's declared + `required_scopes`. The manifest `schema_version` bump is an INT-4 decision. + +Rejected: an opaque connection UUID (does not survive export or `lfx run`, and the UI needs a lookup to display it); +a `SecretStrInput` subclass with a synthetic variable name (password rendering, `load_from_db=True` from +`DatabaseLoadMixin`, the DB-path raise, and the env-name constraint); a dict value (`MCPInput` is precedent, but a +string is simpler for tweaks, env, and manifest sorting; the dict is the parsed form only). + +## 2. Input type + +**Decision: new `ConnectionRefInput` and `FieldTypes.CONNECTION_REF = "connection_ref"`; do not extend +`ConnectionInput`.** + +- `ConnectionRefInput(BaseInputMixin, ConnectionRefMixin, MetadataTraceMixin)` in + `src/lfx/src/lfx/inputs/inputs.py`; `ConnectionRefMixin` in `input_mixin.py` declares `provider: str` (required), + `auth_profile_id: str`, `required_scopes: list[str]`, + `conditional_scopes: list[ConditionalScopeRequirement]`, + `identity_kind: Literal["user", "instance", "any"] = "any"`, `capabilities: list[str] = []` (INT-3 capability + ids). `track_in_telemetry = False`; `CONNECTION_REF` joins `SENSITIVE_FIELD_TYPES` (`input_mixin.py:53`). No + `ToolModeMixin`, mirroring `SecretStrInput`: an agent must never choose a connection at tool-call time, and a + validator rejects `tool_mode=True`. `password=False`; no `load_from_db`. +- Registered in the `InputTypes` union (`inputs.py:1064`) and `lfx.io.__init__`; `InputTypesMap` picks it up for + `instantiate_input`. +- Frontend: an additive `case "connection_ref"` in + `src/frontend/src/components/core/parameterRenderComponent/index.tsx` and the type list in + `src/frontend/src/constants/constants.ts:685`; the picker reads `templateData.provider`, `required_scopes`, + `identity_kind` and shows scope coverage against `granted_scopes` from the connections API (INT-4). The component + index must be regenerated. +- Why not extend `ConnectionInput`: its `ConnectionMixin` (`input_mixin.py:344`: `options`, `connection_link`, + `search_category`, `button_metadata`) models Composio's remote toolkit-list-and-authorize flow, and the frontend + `connect` renderer carries a `connectionLink === "validated"` state machine + (`connectionComponent/index.tsx:58`) tied to it. Changing the `connect` semantics would silently alter a shipped + bundle. `AuthInput` is a hidden status field (`show=False`), not a value carrier. + +## 3. Resolver protocol and discovery + +**Decision: new `ServiceType.CONNECTION_RESOLVER_SERVICE` in both enums, abstract base in lfx, discovered through +`deps.get_connection_resolver()`; the principal rides on the Graph, not on the resolver.** + +- `lfx/services/connection/base.py`: `BaseConnectionResolverService(Service, abc.ABC)` with + `name = ServiceType.CONNECTION_RESOLVER_SERVICE.value`, plus a `ConnectionResolverProtocol` in + `src/lfx/src/lfx/services/interfaces.py` beside `VariableServiceProtocol`. +- Signature: `async def resolve(self, request: ConnectionResolutionRequest) -> ResolvedCredential`, where the request + is a frozen dataclass `{ref, principal: ExecutionPrincipal, required_scopes: frozenset[str], component_id, + flow_id, run_id}`; optional `async def describe(self, ref, principal) -> ConnectionStatus | None` for pickers and + health (default `None`). +- The member is added to `src/lfx/src/lfx/services/schema.py` and + `src/backend/base/langflow/services/schema.py`. `deps.get_connection_resolver()` follows the + `get_checkpoint_service()` pattern (`src/lfx/src/lfx/services/deps.py:204`): registered service, else the built-in + `EnvConnectionResolver` (section 5). +- langflow-base registers `DatabaseConnectionResolverService` with `override=True` in + `src/backend/base/langflow/services/utils.py` in the same block as `AUTHORIZATION_SERVICE` (lines 616-640). + Enterprise overrides through `lfx.toml` `[services] connection_resolver_service = "..."`; + `_register_service_from_path` (`src/lfx/src/lfx/services/manager.py:501`) gets the same subclass check and + fail-closed `RuntimeError` used for `MODEL_PROVIDER_POLICY_SERVICE`, because a credential-critical service must + refuse to start rather than fall back. Enterprise mutation routes use the `external_*` to 409 + `managed_externally` idiom from `api/v1/catalog_policy.py`. +- Explicit shares: the base class exposes `authorize_principal(request, connection_owner_id, owner_kind, + allow_non_interactive) -> None | IntegrationError` implementing the section 4 table; the langflow-base service + asks `BaseAuthorizationService.enforce` for resource `connection`, action `execute`, when + `supports_cross_user_fetch()` is true, matching `get_flow_by_id_or_endpoint_name(widen_for_shares=True)` + (`helpers/flow.py:580-611`). A `user` owner kind without an owner id fails closed; host implementations must not + treat missing ownership metadata as an implicit match. + +Rejected: piggybacking on `VARIABLE_SERVICE` (string-only; the DB variant has no share semantics; a variable named +`LF_CONNECTION__X` could impersonate a connection in DB mode); a callable in `graph.context` (not picklable, copied +by `_copy_graph`, invisible to the CI matrices); a method on `BaseAuthorizationService` (the OSS pass-through may be +the registered authorization service while connections must still work). + +## 4. Principal-aware resolution + +**Decision: introduce `ExecutionPrincipal`, stamped on the Graph by each route family; deny in the resolver as +defense in depth and pre-flight in interactive routes for UX.** + +- `ExecutionPrincipal` is a frozen dataclass in `src/lfx/src/lfx/services/authorization/base.py` beside + `AuthorizationPrincipal`: `kind` drawn from the matrix vocabulary in + `scripts/ci/check_execution_principal_matrix.py` (`actor | flow_owner | deployment_owner | job_owner | + anonymous_public`) plus `headless_operator` for lfx CLI and embedded use; `user_id`, `actor_id`, `family`, + `interactive: bool`, `end_user_id`, `actor_label` (the serve identity string). +- Stamped as `graph.execution_principal` by `build_graph_from_data` and `build_graph_from_db*` + (`src/backend/base/langflow/api/utils/flow_utils.py:47-84`), `serve_app.py`, and `run/base.py`; propagated by + `_copy_graph` and `copy_for_run`; excluded from `__getstate__` and recomputed on workers from `job.user_id` plus + family. Unset resolves to `ExecutionPrincipal.unknown()`, which denies user connections. Today `graph.user_id` + already is the execution principal id per family (the `PUBLIC_ANONYMOUS_ACTOR_ID` check at `graph/base.py:1858`; + webhook uses `flow.user_id`), so this formalizes rather than re-plumbs. +- The table below is the normative input for the later `execution_principal_matrix.json` `connection_resolution` + dimension and checker update (deferred as recorded in section 11): + +| Family | Dependency principal | User connections | Instance connections | +|---|---|---|---| +| interactive_chat, v1_run, openai_responses, voice, workflow_v2 | actor_or_explicit_share | owner or explicit share | per policy | +| legacy_mcp | actor | owner only (no shares) | per policy | +| mcp_projects | actor | owner only; project auth `none` runs as the owner non-interactively, so it requires the per-connection opt-in | per policy | +| webhook | flow_owner | only with per-connection `allow_non_interactive` | per policy | +| deployments | deployment_owner | only with per-connection `allow_non_interactive` | per policy | +| workflow_hitl_v2 | job_owner | as the job owner who started it; re-resolved on the worker, never persisted | per policy | +| legacy_public_chat, a2a (anonymous), workflow_public_v2 | anonymous_public | never | deny by default; Enterprise policy may allow flagged instance connections | +| a2a authenticated sub-path | actor | owner only | per policy | +| lfx run, embedded, lfx serve | headless_operator | not applicable (no database) | environment- or request-provisioned only (section 5) | + +## 5. Headless implementations + +**Decision: one `EnvConnectionResolver` in lfx serves `lfx run`, embedded Python, and `lfx serve`, because the serve +request scope is already a ContextVar the variable service reads.** + +- `lfx/services/connection/env_resolver.py`: `resolve()` computes `ref.env_key()` and calls + `get_variable_service().get_variable(key)`. That one call already implements request scope + (`activate_request_variables` in `src/lfx/src/lfx/cli/common.py:447-449`, `LANGFLOW_REQUEST_VARIABLES` JSON via + `runtime_variables.py`, the `x-langflow-global-var-*` alias), then `safe_getenv` with reserved names denied, + skipped under `no_env_fallback`. No new ContextVar. If the owners want the ticket's two names, + `RequestScopedConnectionResolver` is a trivial subclass (question 12.a.1). +- Trust boundary: in standalone lfx the request scope is the intended injection channel, not a bypass. There is no + database, no user, and no connection-provisioning permission to enforce; the only principal is the serve caller, + who is authenticated by the serve API key and already controls the flow's inputs. A caller can substitute only a + credential they hold, and only for their own request (the scope is a ContextVar, so it never reaches another + caller's request or the process environment); this is exactly how every API key supplied through + `x-langflow-global-var-*` works today. What a caller cannot do is read the operator's environment-provisioned + token, because resolution hands a `ResolvedCredential` to the component and never to the caller, and the + reserved-name denial and `no_env_fallback` rules still apply. If the lfx owner wants operators to be able to pin a + served flow to environment-provisioned connections, `EnvConnectionResolver` gains an `LFX_CONNECTIONS_ENV_ONLY` + switch that skips the request-scoped lookups for `LF_CONNECTION__*` keys (question 12.a.8). +- Wire format for the value: a bare access token (`scopes_verified=False`, `expires_at=None`) or a JSON object + `{"access_token", "token_type", "expires_at", "scopes", "account": {"id", "display", "tenant_id"}}`; + `normalize_parsed_variables` (`request_scope.py:28`) already serializes nested JSON, so detection is "starts with + `{`". Refresh is the injector's job (`refreshable=False`). +- Failure: `ConnectionUnresolvedError` names the handle, the env key, and the JSON form, never a value. `lfx run` + gains `validate_connection_refs_for_env` beside `validate_global_variables_for_env` so the run fails before + execution under `--check-variables`. `lfx serve` surfaces the typed error through the normal component-error path. +- Identity: the `serve_identity.py` label becomes `ExecutionPrincipal(kind="headless_operator", actor_label=...)`; + the `run/_defaults.py` throwaway UUID maps to the same kind. The resolver treats both as instance-or-environment + only. + +## 6. Credential object and the MCP seam + +**Decision: components receive a `CredentialLease`, not a token; resolution is lazy inside the component and never +happens in `update_params_with_load_from_db_fields`.** + +- `ResolvedCredential` (frozen, slots): `access_token: SecretStr`, `token_type`, `expires_at`, `granted_scopes: + frozenset`, `scopes_verified`, `account: ConnectionAccount | None`, `connection_id`, `owner_kind: user | instance | + env`, `provider`, `name`. `__repr__` redacts; `__reduce__` raises so it can never be pickled into a job payload or + cache. +- `CredentialLease` (mutable, in-process): `await lease.get_token()` returns the cached token while it is valid and + re-calls the resolver only when `expires_at` is set and `expires_at - now < 60s` (constants from + `OAuthConnectorBase`: `MIN_EXPIRES_IN_SECONDS` and the 60-second margin in + `src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/connector_base.py:140-260`); in-process single flight via + `asyncio.Lock`. Cross-worker single flight is the host's refresh coordinator (langflow-base), triggered by + `resolve()`. `expires_at=None` is the no-expiry path (bare env tokens, Slack tokens without rotation, API keys): + the lease never computes a delta and never refreshes proactively, `get_token()` always succeeds with the cached + value, and the only recovery is reactive: when the provider answers with `AuthExpiredError` (section 7) the + component re-resolves once through the lease and, if that attempt fails too, raises the typed error without + permitting another refresh attempt on that lease. +- `Component.resolve_connection(field_name) -> CredentialLease` (additive method on `Component`) reads the handle + from the input, builds the request from `self.graph.execution_principal`, and calls + `get_connection_resolver()`. Lazy because `set_attributes(params)` would put the value in `_inputs[...].value` + within reach of trace and telemetry serialization, and because tokens must not be minted for vertices that never + run. +- MCP: a bundle subclasses `MCPPresetComponent`, declares a `ConnectionRefInput`, and implements + `async def _mcp_server_config(self)` (`src/lfx/src/lfx/base/mcp/preset.py:127`) returning + `headers={"Authorization": f"Bearer {await lease.get_token()}"}`. Recorded caveat: `_get_server_key` hashes + `url|sorted(headers)` (`src/lfx/src/lfx/base/mcp/util.py:1290`), so token rotation creates a new session and + orphans the old one until idle cleanup; an optional `session_scope` key in `server_config` is proposed (question + 12.a.7). +- Migration: `OAuthConnectorBase` and the Google bundle's `GoogleOAuthToken` and Gmail `SecretStrInput` JSON + (`src/bundles/google/src/lfx_google/components/google/gmail.py:30`) keep working; a later phase can accept a + `ConnectionRef` in `KBConnectorSource` (see `decisions/kb-oauth-connector-adoption.md`). + +## 7. Typed integration errors + +**Decision: an `IntegrationError` hierarchy with kebab-case codes and a normalization helper, sanitized by +construction.** + +- `lfx/integrations/errors.py`: `IntegrationError(code, message, hint, provider, retryable, http_status, + safe_message, details)`; subclasses `ConnectionUnresolvedError` (`connection-unresolved`), + `ConnectionNotAuthorizedError` (`connection-not-authorized`), `AuthExpiredError` (`auth-expired`), + `ScopeMissingError` (`scope-missing`, with `missing: frozenset`), `RateLimitedError` (`rate-limited`, with + `retry_after`), `ProviderUnavailableError` (`provider-unavailable`), `ActionUnsupportedError` + (`action-unsupported`). `INTEGRATION_ERROR_CODES` is a frozenset and the contract, with the same rule as + `ERROR_CODES` in `extension/errors.py`: adding is additive, removing bumps `BUNDLE_API_VERSION`. +- `normalize_integration_error(exc, *, provider)` maps HTTP status via `extract_http_status` (already unwraps anyio + ExceptionGroups, `base/mcp/util.py`), scrubs with `redact_urls_in_text`, and consults + `register_error_normalizer(provider, fn)` so bundles map SDK exceptions without lfx depending on SDKs. String + values in `details` are scrubbed by the same boundary before the error can reach a client or trace. +- UI and HTTP mapping: `error_details_for_client` + (`src/backend/base/langflow/api/utils/execution_errors.py:20`) gains an `IntegrationError` branch that always emits + `{code, safe_message, hint, provider, retryable, retry_after}` under every `error_policy` + (`owner_debug_delegated_sanitized`, `sanitized`, `provider_sanitized`) and adds `details` and traceback only when + `expose_details=True`. The frontend keys calls to action on `code`: `auth-expired` reconnects, `scope-missing` + grants, `connection-unresolved` connects. + +Rejected: plain `ValueError` strings (the current `OAuthConnectorBase` style, not machine-readable); reusing +`lfx.services.auth.exceptions.TokenExpiredError` (it means the Langflow session JWT); reusing the `ExtensionError` +dataclass (not an `Exception`; loader-specific code namespace). + +## 8. Capability metadata types shared with INT-3 + +**Decision: frozen Pydantic models in `lfx/integrations/capabilities.py`; reserve `ExtensionManifest.integrations` +now.** + +- `IntegrationProvider{provider_id, display_name, icon, auth_profiles: tuple[OAuthProfile, ...], capabilities, + docs_url}`. A provider may expose more than one named profile; Slack has `slack-user-oauth` and + `slack-bot-install`, so bot-token capabilities can never be resolved through a user-token connection by accident. +- `OAuthProfile{id, kind, identity, authorization_url, token_url, supports_pkce, supports_refresh, scope_separator, + default_scopes, client_type_by_context, owner_by_context, tenant_param}`, where `kind` is the matrix schema's + `auth_mode` enum verbatim (`oauth2_authorization_code`, `oauth2_client_credentials`, `oauth2_device_code`, + `service_account`, `service_account_domain_wide_delegation`, `bot_token_install`, `api_key`). The + `client_type_by_context` values are `confidential`, `public`, or `external` and are lifted from each matrix's + `oauth_client_type_by_context`; profiles may omit an unsupported context. This represents both Tauri public-client + loopback and Microsoft's `{tenant}` authority without pretending one provider-wide auth object fits every action. +- `IntegrationCapability{id, display_name, auth_profile_id, identity, required_scopes, conditional_scopes, + risk: read | write | destructive, component_ref, mcp_tool}`. `ConditionalScopeRequirement{scope, role, + condition}` preserves `optional` versus `alternative`; `ScopeCondition{kind: input_present | input_truthy, + input}` is evaluated against the action's declared input schema. The matrix checker rejects a condition that + names a missing input. `ScopeSet.covers(capability, inputs, granted) -> missing` first activates conditional + requirements, then performs provider-aware normalization (Google URL scopes, Graph short names, Slack bot versus + user scopes). The picker and resolver therefore apply the same executable rule instead of interpreting prose. +- Capability ids are the matrices' `action_id` values. `required` rows become `required_scopes`; `optional` and + `alternative` rows become `conditional_scopes` without losing their role or predicate. The capability's + `auth_profile_id` and `identity` must match the selected connection before scope coverage is evaluated. +- `ExtensionManifest.integrations: tuple[IntegrationProvider, ...] = ()` is added as an optional field (additive; + `manifest.py` is in the changelog gate); loader wiring is INT-3. + +Rejected: reusing `ProviderManifestEntry` (model-provider registry semantics would route integrations into +model-provider policy); JSON-schema only (the resolver and the picker need the same scope math in Python). + +## 9. Telemetry hooks + +**Decision: reuse tracing spans for latency and add one small telemetry payload for the error class, with no +identifiers.** + +- `IntegrationActionPayload(BasePayload)` in `src/lfx/src/lfx/services/telemetry/schema.py` (mirrored in + langflow-base): `provider`, `capability`, `ms`, `success`, `error_code` (from `INTEGRATION_ERROR_CODES` or + `other`), `owner_kind`, `principal_kind`. Fits the 2 KB Scarf GET budget like `MCPToolPayload`. Never: connection + id, account, handle, token. +- `lfx/integrations/telemetry.py: integration_action(component, *, provider, capability, owner_kind)` is an async + context manager that measures latency, normalizes and re-raises via section 7, emits the payload, opens a child + OTel span under tracer `APPLICATION_TRACER_NAME` (`observability.py:78`, so it passes the export allowlist) with + closed-vocabulary attributes `integration.provider`, `integration.capability`, `integration.error_code`, + `integration.owner_kind`, and appends a redacted log to the current component trace via `add_log` so LangSmith and + Langfuse users see it. `track_in_telemetry` gating (`component.py:705`) already excludes the input by type. + +## 10. Bundle API impact + +- New public surface: `lfx.io.ConnectionRefInput`; `lfx.integrations` (`ConnectionRef`, `ResolvedCredential`, + `CredentialLease`, the `IntegrationError` family, `INTEGRATION_ERROR_CODES`, `normalize_integration_error`, + `register_error_normalizer`, `IntegrationProvider`, `OAuthProfile`, `IntegrationCapability`, `ScopeSet`, + `integration_action`); `Component.resolve_connection()`; `ExtensionManifest.integrations`. All additive: + `BUNDLE_API_VERSION` stays 1; `BUNDLE_API.md` gains an "Integrations" table and a changelog entry. +- Gate hygiene: add `src/lfx/src/lfx/integrations/*.py` (and arguably `inputs/inputs.py`, `inputs/input_mixin.py`, + `io/__init__.py`) to `IN_SCOPE_PATHS` in `scripts/migrate/check_bundle_api_changelog.py`; list `ConnectionInput` + in the Inputs table as-is since Composio already depends on it. +- lfx floor: bundles that import `ConnectionRefInput` pin `lfx>=1.13` through `scripts/ci/sync_bundle_lfx_pin.py`; + `lfx.compat` is unchanged. + +## 11. Test plan for INT-2 + +- `src/lfx/tests/unit/integrations/`: `ConnectionRef` parse, handle, and `env_key` round trips including + `is_valid_env_var_name`; `env_key` injectivity (the name pattern rejects `Work`, `work-a`, and `work__a`, and a + table of distinct handles maps to distinct keys, including a provider-id pair that differs only in `.` versus + `-` versus `_`); `OAuthProfile.kind` equals the schema's `$defs/auth_mode` enum; `ConnectionRefInput` wire type, + `tool_mode` rejection, `SENSITIVE_FIELD_TYPES` membership, + `instantiate_input` round trip, exclusion from `create_input_schema`; `INTEGRATION_ERROR_CODES` snapshot and + `normalize_integration_error` status mapping including ExceptionGroup unwrapping and URL and email redaction; + `ScopeSet.covers` per provider; manifest `integrations` validation under `extra="forbid"`; `integration_action` + payload and span attributes contain no handle or token. +- `src/lfx/tests/unit/services/connection/`: env resolver bare versus JSON value; request scope beats env (extend + `test_request_scope_isolation.py`); `no_env_fallback` blocks env (extend `test_no_env_fallback_credentials.py`); + reserved names denied; the unresolved error contains no value; `ResolvedCredential` repr and pickle refusal; + `CredentialLease` refresh-before-expiry with a fake clock and in-process single flight, and `expires_at=None` + never refreshes, never raises, and re-resolves exactly once on `AuthExpiredError`; table-driven + `authorize_principal` coverage for headless, actor, non-interactive, anonymous, and unknown principals across + owner kind, owner match, missing-owner metadata, and opt-in. +- CLI: `lfx run` pre-flight fails with the typed error before execution; `lfx serve` error events are sanitized + (extend `test_serve_app.py`). +- Specified here, built in INT-4 and INT-5: the matrix JSON `connection_resolution` dimension and checker update; + an `authz_endpoint_matrix.json` `connections` family; additions to + `src/backend/tests/unit/api/v1/test_execution_principal_contract.py`; the `error_details_for_client` + `IntegrationError` branch under all three policies. + +## 12. Open questions by sign-off owner + +### a. lfx owner + +1. One `EnvConnectionResolver` with documented modes, or two named classes as the ticket text says? +2. Approve `CONNECTION_RESOLVER_SERVICE` in both enums and the fail-closed behavior when an `lfx.toml` class cannot + be loaded. +3. Home of `ExecutionPrincipal`, and whether `headless_operator` joins the matrix vocabulary or lfx stays outside + the matrix. +4. `Component.resolve_connection` as a method versus a free function (Bundle API surface). +5. Extend `IN_SCOPE_PATHS` to `lfx/integrations` and the input modules. +6. Package name `lfx.integrations` versus `lfx.services.connection`. +7. An MCP `session_scope` key so token rotation does not orphan sessions. +8. Whether operators need an `LFX_CONNECTIONS_ENV_ONLY` switch so served flows ignore request-scoped + `LF_CONNECTION__*` keys (section 5 trust boundary). + +### b. langflow-base owner + +1. Handle-only versus handle plus owner kind, and the default user-to-instance fallback policy. +2. Connection as a new share resource type for explicit shares. +3. Per-connection `allow_non_interactive` semantics, including `mcp_projects` with auth `none`. +4. Cross-worker single-flight refresh: a DB lease column versus a Redis lock; the `background_execution` + lease-claim code is the precedent. +5. Encryption envelope: extend the `sso_secret.py` HKDF scheme with a new info label, or the Fernet + `encrypt_api_key` path used by MCP and variables. +6. Artifact manifest schema version for `required_connections`. +7. Desktop: the same `GET /api/v1/connections/{provider}/callback` on `localhost:7860` with a PKCE public client + that is Langflow-owned by default and customer-owned as the override (`decisions/desktop-oauth-ownership.md`), + and the redirect allowlist (`127.0.0.1` loopback; Microsoft ignores the port when matching localhost redirects). +8. Frontend export keeps handles. + +### c. Enterprise owner + +1. Override through an `lfx.toml` subclass plus `external_connections` to 409 `managed_externally` on OSS mutation + routes. +2. Instance-connection policy: who provisions, and which flows or tenants may reference. +3. Anonymous and public paths: hard deny versus policy-allowed flagged instance connections. +4. Component-level audit for resolution denials (today `audit_decision` is route-only). +5. Bring-your-own provider app registration per tenant versus the Langflow-hosted app. +6. Token residency and KMS expectations for the envelope. + +### d. frontend owner + +1. The `connection_ref` renderer and picker API shape (`GET /api/v1/connections?provider=`) with scope coverage. +2. The `code` to call-to-action mapping. +3. The export scrubbing rule for `connection_ref`. +4. Component index regeneration and i18n. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| Enterprise owner | | | | +| frontend owner | | | | diff --git a/design/dedicated-integrations/decisions/TEMPLATE.md b/design/dedicated-integrations/decisions/TEMPLATE.md new file mode 100644 index 000000000000..2ee16ec1b60f --- /dev/null +++ b/design/dedicated-integrations/decisions/TEMPLATE.md @@ -0,0 +1,59 @@ +# + +Status: draft +Decision ID: +Applies to: +Owners (sign-off roles): , , , , +Last verified: YYYY-MM-DD + + + +## Context + +Why this decision is on the critical path and which INT tickets block on it. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | | | | | + +Every fact used in Options or Decision appears here. Reuse the matrix `sources` ids in parentheses where one exists. + +## Options + +### Option A: + +Pros, cons, and what it costs (engineer-weeks, calendar time, recurring obligations). + +### Option B: + +## Decision + +One paragraph, imperative. For restricted-scope records, one `### ` subsection per scope, each ending in +`Decision: avoid | accept_with_casa | accept_exempt | defer` (must match `restricted_scope_decisions` in the matrix). + +## Consequences + +Matrix rows that flip include/exclude/defer; contract or frontend surfaces affected; estimate delta. + +## Re-open trigger + +Concrete observable events (for example "Google Workspace MCP reaches GA", "Work IQ MCP drops the Copilot license +requirement", "mcp.slack.com documents bot-token support", "Google reclassifies gmail.send"). Include a re-verify-by date. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| Enterprise owner | | | | +| frontend owner | | | | +| product owner | | | | diff --git a/design/dedicated-integrations/decisions/desktop-oauth-ownership.md b/design/dedicated-integrations/decisions/desktop-oauth-ownership.md new file mode 100644 index 000000000000..a5fcc680cedd --- /dev/null +++ b/design/dedicated-integrations/decisions/desktop-oauth-ownership.md @@ -0,0 +1,107 @@ +# Desktop OAuth registrations: Langflow-owned public clients by default + +Status: accepted +Decision ID: desktop-oauth-ownership +Applies to: matrices/google.json, matrices/microsoft.json, matrices/slack.json (`oauth_app_owner_by_context.desktop`); the `desktop` deployment context of every included action; the INT-5 named OAuth profiles +Owners (sign-off roles): hosted-app owner, langflow-base owner, release owner +Last verified: 2026-09-02 + +## Context + +The gate froze `oauth_app_owner_by_context` on 2026-09-01 as Langflow-owned for hosted and customer-owned for +self-managed, Desktop, and headless. The self-managed cell is forced by the providers: an OAuth registration must +list its redirect URIs, a self-managed instance lives at a URL Langflow cannot know, and a Langflow-operated redirect +relay is out of scope for 1.13. The Desktop cell was inherited from self-managed because Desktop runs the same +backend on `localhost:7860` and returns through the same callback route. + +On 2026-09-02 the release owner asked why Desktop, which is wrapped OSS from Langflow's side, does not get the same +zero-setup connect as hosted. From the providers' side Desktop is a native application, and every wave-1 provider +supports shipping a vendor-owned public client inside a distributed native application: the redirect is a loopback +address that is the same on every install, so one Langflow-owned registration serves every Desktop user. This record +flips the Desktop default and keeps customer-owned registrations as the override that self-managed already has. +Blocks the Desktop entries of INT-5 and the Desktop runbook of INT-14. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | Google installed applications, which include the "Desktop app" client type, "cannot keep secrets"; the loopback redirect is `http://127.0.0.1:port` or `http://[::1]:port` with the app listening locally; PKCE is recommended (`google-oauth2-native-app`) | https://developers.google.com/identity/protocols/oauth2/native-app | 2026-09-02 | high | +| 2 | Google app verification is tied to the project's OAuth consent screen (brand, authorized domains, project contact), not to an individual client ID, so a Desktop client created in the hosted project carries the hosted app's brand and sensitive-scope verification (`google-oauth-verification-requirements`) | https://support.google.com/cloud/answer/13464321 | 2026-09-02 | high | +| 3 | Microsoft public client applications "can't have client secrets"; an app registration that serves a public client enables the public client flow (`entra-public-client-apps`) | https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-applications | 2026-09-02 | high | +| 4 | One Entra app registration carries redirect URIs for several platforms (Web; Mobile and desktop applications); `http://localhost` is valid, the port is ignored when matching localhost redirect URIs, `127.0.0.1` is preferred, and an `http` loopback URI is added through the manifest `replyUrlsWithType` attribute (`entra-reply-url`) | https://learn.microsoft.com/en-us/entra/identity-platform/reply-url | 2026-09-02 | high | +| 5 | Slack: enabling PKCE marks the app a public client and is one-way; PKCE-opted localhost redirects count as desktop; "Desktop redirects are not allowed to request bot scopes" (`slack-pkce`) | https://docs.slack.dev/authentication/using-pkce/ | 2026-09-01 | high | +| 6 | Commercially distributed non-Marketplace Slack apps get 1 request per minute with a 15-message cap on `conversations.replies` and `conversations.history` (`slack-conversations-replies`) | https://docs.slack.dev/reference/methods/conversations.replies | 2026-09-01 | high | +| 7 | Langflow Desktop is a Tauri v2 wrapper that runs the backend on `localhost:7860`, so the OAuth return is the self-managed callback route (`frontend-surfaces.md` finding 3, `connection-contract.md` question 12.b.7) | https://github.com/langflow-ai/langflow-desktop | 2026-09-01 | high | + +## Options + +### Option A: Keep Desktop customer-owned (the 2026-09-01 default) + +Pros: no additional Langflow-operated registrations; the Desktop guide is the self-managed guide. +Cons: every Desktop user must create a Google Cloud project, an Entra app registration, and a Slack app before the +first connect, which is the setup hosted exists to remove; Desktop is the deployment least likely to have an +administrator to do it; the least-operated context gets three provider setup guides. +Cost: none in engineer-weeks; the adoption cost lands on every Desktop user. + +### Option B: Langflow-owned public clients for Desktop, customer-owned as the override (selected) + +Google: a "Desktop app" client in the hosted Google Cloud project with an embedded client id, PKCE, and the loopback +redirect (fact 1); it carries the hosted brand and sensitive-scope verification (fact 2); the restricted-scope +decision applies unchanged, so Desktop gets the same five actions as hosted. +Microsoft: the hosted app registration gains a Mobile and desktop applications platform with the `127.0.0.1` +loopback redirect and public client flows enabled (facts 3 and 4); one registration, one publisher verification. +Slack: a second Langflow-owned Slack app with PKCE enabled, because PKCE opt-in is one-way and cannot share the +hosted confidential-client app (fact 5); bot actions stay absent from the `desktop` context (fact 5). +The customer-owned override is the path self-managed uses, so Workspace or Entra tenants that require their own +registration lose nothing. +Pros: Desktop connect is zero-setup; the authentication matrix collapses to Langflow-owned (hosted, Desktop) versus +customer-owned (self-managed, headless); INT-14 still tests two registration modes times two client types. +Cons: the hosted-app owner operates one more Slack app and two more redirect entries; the hosted Google app's user +cap and verification status now also gate Desktop; the Desktop Slack app is a distributed non-Marketplace app unless +listed, so `conversations.replies` runs at the reduced tier (fact 6), the same question hosted already carries. +Cost: about 0.25 engineer-weeks in INT-5 for the Desktop entries in the named OAuth profiles and the registration +selector; no calendar lead time beyond hosted's, because the Google and Microsoft verifications are shared. + +### Option C: Langflow-owned for Desktop with no customer-owned override + +Rejected. Workspace and Entra tenants that block third-party applications, and any tenant that wants an Internal +user type project for the 1.14 restricted-scope profile, need their own registration on Desktop as well. + +## Decision + +Option B. `oauth_app_owner_by_context.desktop` is `langflow` in all three matrices and +`oauth_client_type_by_context.desktop` stays `public`. INT-5 ships Desktop entries in the named OAuth profiles +(`connection-contract.md` section 8, `owner_by_context` and `client_type_by_context`) that point at the +Langflow-owned public clients, with the customer-owned registration selectable per provider exactly as on +self-managed. Slack bot actions remain absent from the `desktop` deployment context. The Desktop build embeds client +ids only; it never embeds a client secret for any provider (facts 1 and 3). + +## Consequences + +- Matrices: the three `desktop` owner cells flip to `langflow`. No action row changes, because the Desktop callback + mechanism (`loopback_redirect`) and client type (`public`) were already recorded; `microsoft.json` gains the + `entra-public-client-apps` source. +- `connection-contract.md` question 12.b.7, `frontend-surfaces.md` finding 3, and the Decision and Consequences of + `decisions/substrate-slack.md` reference this record. +- `estimate.md`: INT-5 rises from 5 to 5.25 engineer-weeks (total 48.75); two Desktop rows join the external + lead-time table. +- INT-14 hosted-app runbook: create the Google Desktop client in the hosted project; add the Entra Mobile and desktop + applications platform and enable public client flows; create and PKCE-enable the second Slack app; record the + three client ids in the bundles' provider profiles. + +## Re-open trigger + +- A provider begins requiring per-installation registration for native applications or withdraws loopback redirects + for public clients, or +- the hosted Google application enters a state (user cap, verification lapse) that Desktop must not inherit, or +- product decides Desktop must not depend on Langflow-operated registrations. + +Re-verify by: the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| hosted-app owner | | | | +| langflow-base owner | | | | +| release owner | Eric Hare | 2026-09-02 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/decisions/google-restricted-scopes.md b/design/dedicated-integrations/decisions/google-restricted-scopes.md new file mode 100644 index 000000000000..63918823bc05 --- /dev/null +++ b/design/dedicated-integrations/decisions/google-restricted-scopes.md @@ -0,0 +1,108 @@ +# Google restricted scopes on the Langflow-owned hosted app: CASA or avoid + +Status: accepted +Decision ID: google-restricted-scopes +Applies to: matrices/google.json; scopes gmail.readonly, drive.readonly, drive.metadata.readonly; actions google.gmail.search, google.drive.list, google.drive.fetch +Owners (sign-off roles): hosted-app owner, release owner, Enterprise owner, langflow-base owner +Last verified: 2026-09-01 + +## Context + +Hosted Langflow ships in 1.13 with a Langflow-owned External Google OAuth application. Any restricted scope on that +application triggers Google's restricted-scope verification and, because the hosted backend accesses the data +through a third-party server, a CASA security assessment with annual recertification. Three wave-1 candidate +actions carry restricted scopes. This record decides, per scope, whether the hosted app accepts CASA or wave 1 +avoids the scope, and how self-managed deployments with customer-owned applications are treated. Blocks INT-10 and +the hosted rows of the authentication matrix. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | `gmail.readonly`, `gmail.compose`, `gmail.metadata`, `gmail.modify`, `mail.google.com` are restricted; `gmail.send` is sensitive; `gmail.labels` is non-sensitive | https://developers.google.com/workspace/gmail/api/auth/scopes | 2026-09-01 | high | +| 2 | `drive.readonly`, `drive.metadata.readonly`, `drive` are restricted; `drive.file` is non-sensitive and recommended with the Picker API | https://developers.google.com/workspace/drive/api/guides/api-specific-auth | 2026-09-01 | high | +| 3 | users.messages.list: the `q` parameter "cannot be used when accessing the api using the gmail.metadata scope", so there is no narrower Gmail search scope than `gmail.readonly` | https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list | 2026-09-01 | high | +| 4 | Restricted-scope verification: brand verification first (2-3 business days); CASA under the App Defense Alliance by Google-empanelled assessors when restricted data is accessed "from or through a third-party server"; "can potentially take several weeks"; reverification "at least every 12 months" after the Letter of Assessment | https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification | 2026-09-01 | high | +| 5 | Exemptions: Internal user type projects owned by a Google Workspace or Cloud Identity organization; domain-wide installations still require app verification when restricted or sensitive scopes are used | same | 2026-09-01 | high | +| 6 | Unverified apps face a user cap and an unverified-app screen; Testing publishing status caps at 100 test users with 7-day token expiry | https://support.google.com/cloud/answer/15549945 | 2026-09-01 | high | +| 7 | Sensitive-scope verification (needed for `gmail.send` and the Calendar scopes regardless of this decision) "typically takes 3-5 business days" | https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification | 2026-09-01 | high | +| 8 | The Workspace Gmail and Drive MCP servers request `gmail.readonly`, `gmail.compose`, `drive.readonly`, so the substrate choice does not avoid the restricted tier | https://developers.google.com/workspace/guides/configure-mcp-servers | 2026-09-01 | high | +| 9 | Workspace admins can mark an app Trusted, Limited, Specific Google data, or Blocked; Limited apps cannot reach services an admin marks Restricted; admins can trust internal apps for restricted APIs | https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data | 2026-09-01 | high | + +## Options + +### Option A: Accept CASA for the hosted app + +Pros: Gmail search and full Drive read ship in wave 1 on hosted. +Cons: several weeks of assessment lead time on the critical path of a release (fact 4), an annual recurring +obligation, assessor cost, and a compliance surface (secure storage of restricted data, documentation) that the +gate cannot size. If the assessment slips, hosted Gmail and Drive read actions slip with it. +Cost: unknown assessor fee; several weeks calendar; annual recert. Not inside any INT ticket today. + +### Option B: Avoid restricted scopes on the hosted app in 1.13 (recommended) + +The hosted application registers only non-sensitive and sensitive scopes: `gmail.send`, `drive.file`, +`calendar.events`, `calendar.events.readonly`. Consequences per action: Gmail search is excluded from wave 1; Drive +list and fetch ship on `drive.file` only (files the app created or the user opened with it, with the Picker as a +later enhancement). +Pros: only sensitive-scope verification (3-5 business days, fact 7) stands between the hosted app and production; +no recurring assessment; Gmail send, Calendar list, Calendar create, and Drive on app-scoped files still ship. +Cons: Gmail search, the second most requested Google action, is not in wave 1 on any deployment that uses the +default scope set. + +### Option C: Avoid on hosted, allow a restricted scope profile on self-managed (deferred variant of B) + +Same as B for the hosted app. Self-managed customers with an Internal user type project (fact 5) could enable a +restricted profile on their customer-owned application without CASA; External self-managed projects would own their +own verification. Pros: keeps Gmail search reachable for Workspace-organization customers. Cons: two scope sets +means two component behaviours, two documentation paths, and a policy key to gate the profile; it widens INT-7 and +INT-10 and is not sized. Proposed as the first 1.14 candidate, not as 1.13 scope. + +## Decision + +Option B, confirmed by the release owner on 2026-09-01. + +### https://www.googleapis.com/auth/gmail.readonly + +Not requested by the Langflow-owned hosted application in 1.13. `google.gmail.search` is excluded from wave 1 and +carried to the 1.14 candidate list under Option C. +Decision: avoid + +### https://www.googleapis.com/auth/drive.readonly + +Not requested in 1.13. `google.drive.fetch` ships on `drive.file` only. +Decision: avoid + +### https://www.googleapis.com/auth/drive.metadata.readonly + +Not requested in 1.13. `google.drive.list` ships on `drive.file` only. +Decision: avoid + +## Consequences + +- `matrices/google.json`: `google.gmail.search` decision `exclude`; `google.drive.list` and `google.drive.fetch` drop + their restricted scope entries and keep `drive.file`; `restricted_scope_decisions` entries flip to `avoid`. +- Google wave 1 include set (5 actions): Gmail send, Drive list (app files), Drive fetch (app files), Calendar list, + Calendar create. Room for up to 3 alternates under the cap of 8; Drive upload (`drive.file`, non-sensitive) and + Calendar update are the candidates if the release owner wants more Google surface. +- Hosted-app external dependencies: brand verification and sensitive-scope verification only; CASA leaves the + estimate. +- The auth matrix's hosted Google row records "no restricted scopes" as a constraint of the 1.13 registration. + +## Re-open trigger + +- Product commits to Gmail search on hosted for a dated release (then Option A starts immediately, given the lead + time), or +- Google removes `gmail.readonly` or `drive.metadata.readonly` from the restricted list, or +- Option C is sized and accepted for 1.14. + +Re-verify by: the 1.14 planning gate, or earlier if Option A is triggered. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| hosted-app owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | +| Enterprise owner | | | | +| langflow-base owner | | | | diff --git a/design/dedicated-integrations/decisions/kb-oauth-connector-adoption.md b/design/dedicated-integrations/decisions/kb-oauth-connector-adoption.md new file mode 100644 index 000000000000..8631a0b4d5de --- /dev/null +++ b/design/dedicated-integrations/decisions/kb-oauth-connector-adoption.md @@ -0,0 +1,78 @@ +# Knowledge-base OAuth connectors: adopt the connection contract in 1.13 or defer + +Status: accepted +Decision ID: kb-oauth-connector-adoption +Applies to: src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/ (OAuthConnectorBase and the Google Drive, OneDrive, SharePoint, Microsoft Graph sources); INT-2, INT-10, INT-11 +Owners (sign-off roles): lfx owner, langflow-base owner, release owner +Last verified: 2026-09-01 + +## Context + +The knowledge-base ingestion layer already has an OAuth base class and four cloud sources that were stubbed out +"until the first OAuth provider lands". The connection contract is that provider. This record decides whether the +KB sources adopt the contract in 1.13 or stay on their bring-your-own-refresh-token design until 1.14. + +## Facts (with citations) + +| # | Fact | Source | Verified on | Confidence | +|---|------|--------|-------------|------------| +| 1 | `OAuthConnectorBase(KBConnectorSource)` at `connector_base.py:140` resolves client id, client secret, and refresh token from three Langflow variables and exchanges the refresh token itself; access tokens are cached in-process with a 60 s margin | repo, `src/lfx/src/lfx/base/knowledge_bases/ingestion_sources/connector_base.py` | 2026-09-01 | high | +| 2 | The module docstring records the deferral: "No shared OAuth plumbing here: Phase 3B+ adds a dedicated OAuthConnectorBase subclass with token-refresh logic once the first OAuth provider lands" | same file, lines 1-25 | 2026-09-01 | high | +| 3 | `google_drive.py`, `onedrive.py`, `sharepoint.py`, `microsoft_graph.py` raise `NotImplementedError` and are not registered; `__init__.py` registers only `FILE_UPLOAD` and `FOLDER` | same directory | 2026-09-01 | high | +| 4 | KB ingestion runs as a background job whose executing identity is the job owner, so a connection used by ingestion is resolved non-interactively | `scripts/ci/execution_principal_matrix.json` (workflow_hitl_v2 and deployments families) and `connection-contract.md` section 4 | 2026-09-01 | medium | +| 5 | Drive and Graph ingestion need read scopes: Drive read beyond `drive.file` is restricted (CASA) and Graph `Files.Read.All` or `Sites.Read.All` are delegated without admin consent | `matrices/google.json`, `matrices/microsoft.json` | 2026-09-01 | high | + +## Options + +### Option A: Adopt in 1.13 (chosen) + +`KBConnectorSource` accepts a `ConnectionRef`; `OAuthConnectorBase` becomes a thin adapter over +`Component.resolve_connection`; the four stubbed sources are re-registered against the Google and Microsoft +connections. Pros: one credential path for actions and ingestion; the stubs finally ship. Cons: ingestion is a +background job, so it needs the `allow_non_interactive` opt-in and the job-owner principal from INT-6 on day one; +Drive ingestion beyond app-scoped files needs a restricted scope the hosted app avoids +(`decisions/google-restricted-scopes.md`), so on hosted only OneDrive and SharePoint ingestion would actually work; +the KB UI has its own connector picker (`GET /api/v1/knowledge_bases/connectors`) that would need the connection +picker. Cost: roughly 1.5 engineer-weeks across INT-10 and INT-11 plus KB UI work not in any ticket. + +### Option B: Defer to 1.14, keep the contract compatible (recommended by the gate, not chosen) + +The KB sources stay disabled in 1.13; `OAuthConnectorBase` keeps bring-your-own refresh tokens for any customer who +already uses it. INT-2 keeps `KBConnectorSource` able to accept a `ConnectionRef` later without a breaking change +(the handle is a string field). Pros: no new scope on 1.13; the first consumer of the non-interactive opt-in is the +webhook and deployment path, which INT-6 already tests. Cons: the stubs stay stubs for one more release. +Cost: none in 1.13. + +## Decision + +Option A, chosen by the release owner on 2026-09-01 against the gate's recommendation. The knowledge-base OAuth +connectors adopt the connection contract in 1.13: `KBConnectorSource` accepts a connection handle, `OAuthConnectorBase` +becomes a thin adapter over `Component.resolve_connection`, and the Google Drive, OneDrive, SharePoint, and +Microsoft Graph sources are re-registered against the Google and Microsoft connections. + +## Consequences + +- INT-10 gains the Drive ingestion source and INT-11 gains the OneDrive, SharePoint, and Graph sources; the KB + connector picker (`GET /api/v1/knowledge_bases/connectors`) gains the connection picker. Roughly 1.5 engineer-weeks + across INT-10 and INT-11 plus KB UI work, now carried in `estimate.md`. +- KB ingestion runs as a background job, so it is the first day-one consumer of the per-connection + `allow_non_interactive` opt-in and the job-owner principal from INT-6; INT-6 must land before the KB sources are + enabled, and the ingestion source must surface the typed `connection-not-authorized` error when the opt-in is off. +- On hosted, Drive ingestion is limited to `drive.file` (files the app created or the user picked) under + `decisions/google-restricted-scopes.md`; OneDrive and SharePoint ingestion are unaffected. +- The `source_config` shape for existing FILE_UPLOAD and FOLDER sources does not change; the connection handle is an + additional field on the cloud sources only. + +## Re-open trigger + +- INT-4 and INT-6 land with the non-interactive opt-in tested, and a customer asks for Drive, OneDrive, or SharePoint + ingestion, or +- the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/decisions/palette-naming.md b/design/dedicated-integrations/decisions/palette-naming.md new file mode 100644 index 000000000000..6cc60335fc47 --- /dev/null +++ b/design/dedicated-integrations/decisions/palette-naming.md @@ -0,0 +1,80 @@ +# Palette naming and sidebar grouping for native connectors next to Composio components + +Status: accepted +Decision ID: palette-naming +Applies to: display names and component classes in matrices/*.json; SIDEBAR_BUNDLES in src/frontend/src/utils/styleUtils.ts; INT-10, INT-11, INT-12 +Owners (sign-off roles): frontend owner, product owner, release owner +Last verified: 2026-09-01 + +## Context + +Composio wrappers for the same products already exist in the opt-in `lfx-bundles` metapackage, and the Google +bundle already ships loader components. Users who install `langflow[bundles]` will see both families. This record +fixes display names, class names, and sidebar groups before INT-10 to INT-12 create classes, because bare class +names must be unique across bundles and the migration table is append-only. + +## Facts (with citations) + +| # | Fact | Source | Verified on | Confidence | +|---|------|--------|-------------|------------| +| 1 | Composio components use bare product nouns as display names: `ComposioGmailAPIComponent` "Gmail", `ComposioOutlookAPIComponent` "Outlook", `ComposioSlackAPIComponent` "Slack", `ComposioSlackbotAPIComponent` "Slackbot" | `src/bundles/lfx-bundles/src/lfx_bundles/composio/{gmail,outlook,slack,slackbot}_composio.py:5` | 2026-09-01 | high | +| 2 | The Google bundle ships `GmailLoaderComponent` "Gmail Loader", `GoogleDriveComponent` "Google Drive Loader", `GoogleDriveSearchComponent` "Google Drive Search", and `GoogleOAuthToken` "Google OAuth Token" (`legacy = True`) | `src/bundles/google/src/lfx_google/components/google/*.py` | 2026-09-01 | high | +| 3 | `SIDEBAR_BUNDLES` already has separate `Gmail` (line 467) and `Google` (line 468) groups, a `Composio` group (line 451), and an `Azure` group (line 441); there is no `Microsoft` or `Slack` group | `src/frontend/src/utils/styleUtils.ts` | 2026-09-01 | high | +| 4 | `migration_table.json` carries rows for `GmailLoaderComponent`, `GoogleDriveComponent`, `GoogleOAuthToken`, and `ComposioGmailAPIComponent`; bare names must stay unique and the table is append-only | `src/lfx/src/lfx/extension/migration/migration_table.json`, `scripts/migrate/check_bare_names.py`, `check_migration_append_only.py` | 2026-09-01 | high | +| 5 | Composio components are not in the default install (the `lfx-bundles` metapackage is opt-in in 1.12) | `src/bundles/lfx-bundles/pyproject.toml`; release notes | 2026-09-01 | high | + +## Options + +### Option A: Rename Composio components to " (Composio)" + +Pros: bare product names free for native components. Cons: changes a shipped bundle's display names (saved flows +keep working because class names are unchanged, but search and documentation churn); the plan's rule is that +Composio components are not silently reclassified. + +### Option B: Native components use "Product: Verb Object"; Composio names unchanged; new provider groups (recommended) + +Native display names carry the product and the action, for example "Gmail: Send Email", "Outlook: Send Mail", +"Slack: Post Message (as app)", exactly as the matrices already record. Composio keeps "Gmail", "Outlook", "Slack", +"Slackbot" under the `Composio` sidebar group. Search for "Gmail" returns both, distinguishable by label and group. +Class names are product-prefixed (`GmailSendComponent`, `OutlookSendComponent`, `SlackPostAsAppComponent`) and never +collide with existing bare names (fact 4). + +### Option C: One "Connectors" category for all native provider actions + +Pros: a single discoverable home. Cons: cuts across the bundle-per-provider packaging; the sidebar already groups by +bundle, and a cross-bundle category needs runtime binding the sidebar does not have (`frontend-surfaces.md` B8). + +## Decision + +Option B, with these sidebar rules: + +1. Native Google actions join the existing `Google` group. The `Gmail` group (which today exists only for the Gmail + loader) is folded into `Google` in INT-10; `GmailLoaderComponent` keeps its class name and display name and is + re-grouped only. `GoogleOAuthToken` is hidden from the palette when connections land, per INT-10. +2. New `Microsoft 365` group (icon `Microsoft`) for `lfx-microsoft`, kept separate from the existing `Azure` group, + which holds the Azure OpenAI model components. +3. New `Slack` group (icon `Slack`) for `lfx-slack`; the two Composio Slack components stay under `Composio`. +4. Display names follow "Product: Verb Object", with "(as user)" or "(as app)" suffixes only where one product has + both identities (Slack). +5. Class names are `Component` and are checked against the migration table's bare names + before INT-10 to INT-12 open. + +## Consequences + +- `SIDEBAR_BUNDLES` gains two entries and loses one; the icon registry gains `Microsoft`, `Slack`, `Teams`, + `Outlook`, `OneDrive`, `SharePoint` (see `frontend-surfaces.md` A7, A8). +- Documentation pages `bundles-microsoft.mdx` and `bundles-slack.mdx` name the groups the same way. +- No Composio file changes. + +## Re-open trigger + +- Composio components join the default install, or +- the sidebar gains runtime bundle binding (B8), which would make a cross-provider "Connectors" view cheap. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| frontend owner | | | | +| product owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/decisions/substrate-google.md b/design/dedicated-integrations/decisions/substrate-google.md new file mode 100644 index 000000000000..8607dd0cdce7 --- /dev/null +++ b/design/dedicated-integrations/decisions/substrate-google.md @@ -0,0 +1,78 @@ +# Substrate decision: Google Workspace + +Status: accepted +Decision ID: substrate-google +Applies to: matrices/google.json, the five included actions (google.gmail.send, google.drive.list, google.drive.fetch, google.calendar.list, google.calendar.create); google.gmail.search is excluded by decisions/google-restricted-scopes.md and is not covered here +Owners (sign-off roles): lfx owner, langflow-base owner, Enterprise owner, hosted-app owner, release owner +Last verified: 2026-09-01 + +## Context + +The plan keeps MCP as the strategic substrate but adopts a provider's official server only when it is generally +available with an identity model and scope set that fit the approved matrix. This record decides which substrate +the five included wave-1 Google actions run on (Gmail search is excluded by `google-restricted-scopes.md`; if that +record is re-opened, the excluded action follows this decision). It blocks INT-10 and decides whether INT-9 (pinned +MCP mode) is needed for Google in 1.13. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | The Workspace MCP servers (Gmail, Drive, Docs, Sheets, Slides, Calendar, Chat, People) are available "as part of the Google Workspace Developer Preview Program" | https://developers.google.com/workspace/guides/configure-mcp-servers | 2026-09-01 | high | +| 2 | Preview program terms: features "may not be included in public applications prior to the General Availability (GA) announcement" and end users outside the developer's own domain may not be given access before GA | https://developers.google.com/workspace/preview | 2026-09-01 | high | +| 3 | The Gmail server requests `gmail.readonly` and `gmail.compose`; the Drive server requests `drive.readonly` and `drive.file`; users bring their own Google Cloud OAuth client | https://developers.google.com/workspace/guides/configure-mcp-servers | 2026-09-01 | high | +| 4 | `gmail.readonly`, `gmail.compose`, `drive.readonly` are restricted scopes; `gmail.send` is sensitive; `drive.file` is non-sensitive | https://developers.google.com/workspace/gmail/api/auth/scopes and https://developers.google.com/workspace/drive/api/guides/api-specific-auth | 2026-09-01 | high | +| 5 | The Gmail, Drive, and Calendar REST APIs and google-api-python-client are GA with published quotas | https://developers.google.com/workspace/gmail/api/reference/quota (and Drive, Calendar quota pages) | 2026-09-01 | high | +| 6 | Restricted scopes on an app that touches data through a third-party server require a CASA assessment that "can potentially take several weeks" and 12-month reverification | https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification | 2026-09-01 | high | + +## Options + +### Option A: Official Workspace MCP servers for all wave-1 actions + +Pros: aligns with the strategic substrate; tool schemas maintained by Google. +Cons: preview terms forbid shipping to end users outside our own domain before GA (fact 2), which rules out hosted, +self-managed, and Desktop; the Gmail server's `gmail.compose` scope raises Gmail send from sensitive to restricted +(facts 3, 4) and would force CASA on the hosted app for an action that does not otherwise need it; no published +rate limits or GA date. Cost: blocks INT-10 until an unknown GA date. + +### Option B: SDK/REST for all wave-1 actions; swap to MCP per action after GA (recommended) + +Pros: every substrate is GA today (fact 5); scope tiers are chosen per action, so Gmail send stays sensitive; no +dependency on the preview program; INT-9 pinned mode is not needed for Google in 1.13. The component identity and +saved-flow schema are Langflow-owned, so a later swap to the MCP server changes only the adapter. +Cons: Langflow maintains thin adapters over google-api-python-client for five methods. +Cost: inside the INT-10 estimate (5 engineer-weeks); no external lead time. + +### Option C: Mixed (MCP for Calendar, SDK for Gmail and Drive) + +Pros: none over B while the Calendar server is also in the preview program (fact 1). +Cons: two auth and error paths for one provider; still blocked by fact 2. + +## Decision + +Wave-1 Google actions run on the Google APIs SDK (google-api-python-client) with least-privilege scopes chosen per +action. The official Workspace MCP servers are not adopted in 1.13. `substrate_decision.chosen` in +`matrices/google.json` is `["sdk"]`. + +## Consequences + +- INT-9 pinned MCP mode is not on the Google critical path; INT-10 depends on INT-3 and INT-5 only. +- The Gmail send action stays on `gmail.send` (sensitive) and does not depend on the CASA decision. +- INT-10 documents the GA-swap procedure for one Google action so the substrate can change without touching saved flows. + +## Re-open trigger + +- Google announces GA of any Workspace MCP server, or +- Google publishes MCP-server scope sets that avoid restricted scopes for the actions in wave 1. + +Re-verify by: the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| Enterprise owner | | | | +| hosted-app owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/decisions/substrate-microsoft.md b/design/dedicated-integrations/decisions/substrate-microsoft.md new file mode 100644 index 000000000000..da5c5f63eec0 --- /dev/null +++ b/design/dedicated-integrations/decisions/substrate-microsoft.md @@ -0,0 +1,67 @@ +# Substrate decision: Microsoft 365 and Teams + +Status: accepted +Decision ID: substrate-microsoft +Applies to: matrices/microsoft.json, all actions +Owners (sign-off roles): lfx owner, langflow-base owner, Enterprise owner, hosted-app owner, release owner +Last verified: 2026-09-01 + +## Context + +Decides which substrate the eight wave-1 Microsoft actions run on. Blocks INT-11. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | Work IQ MCP: "This is a preview feature. Preview features aren't meant for production use" | https://learn.microsoft.com/en-us/microsoft-agent-365/tooling-servers-overview | 2026-09-01 | high | +| 2 | "You must have a Microsoft 365 Copilot license to use Work IQ MCP servers" | same | 2026-09-01 | high | +| 3 | Work IQ servers are tenant-scoped (`https://agent365.svc.cloud.microsoft/agents/tenants/{tenantId}/servers/mcp_MailTools`), reached through a customer enterprise app holding `WorkIQ-*` permissions, and governed in the Microsoft 365 admin center | same | 2026-09-01 | high | +| 4 | Microsoft Graph v1.0 REST covers every wave-1 action with delegated permissions that require no admin consent | https://learn.microsoft.com/en-us/graph/permissions-reference | 2026-09-01 | high | +| 5 | Graph publishes per-service throttling limits (Outlook 10,000 requests per 10 minutes per mailbox; Teams 1 request per second per chat or channel) | https://learn.microsoft.com/en-us/graph/throttling-limits | 2026-09-01 | high | + +## Options + +### Option A: Work IQ MCP servers + +Pros: Microsoft-maintained tool schemas; admin-center governance. +Cons: preview and "not meant for production use" (fact 1); smuggles a per-tenant Microsoft 365 Copilot license into +a Langflow feature (fact 2); tenant-scoped endpoints and a customer-registered enterprise app make a Langflow-owned +hosted app impossible (fact 3). Cost: blocks INT-11 on an unknown GA date and on customer licensing. + +### Option B: Microsoft Graph REST with delegated permissions (recommended) + +Pros: GA, no admin consent for any wave-1 permission (fact 4), published throttling (fact 5), one auth path for +hosted, self-managed, and Desktop; the msgraph-sdk-python or plain httpx suffices. +Cons: Langflow maintains eight thin adapters. Cost: inside the INT-11 estimate. + +### Option C: Mixed + +No wave-1 action benefits from MCP while facts 1 to 3 hold. + +## Decision + +Wave-1 Microsoft actions run on Microsoft Graph v1.0 REST with delegated permissions. Application permissions are +excluded. Work IQ MCP is not adopted in 1.13. `substrate_decision.chosen` in `matrices/microsoft.json` is `["rest"]`. + +## Consequences + +- INT-11 depends on INT-3 and INT-5 only; INT-9 is not on the Microsoft path. +- The hosted app's external dependency is publisher verification, not a Copilot license. + +## Re-open trigger + +- Work IQ MCP reaches GA and the Copilot-license requirement is dropped or judged acceptable for target customers, or +- Microsoft publishes a Graph-permission-based (non-tenant-scoped) MCP endpoint. + +Re-verify by: the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| Enterprise owner | | | | +| hosted-app owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/decisions/substrate-slack.md b/design/dedicated-integrations/decisions/substrate-slack.md new file mode 100644 index 000000000000..5c3ae5c63753 --- /dev/null +++ b/design/dedicated-integrations/decisions/substrate-slack.md @@ -0,0 +1,95 @@ +# Substrate decision: Slack + +Status: accepted +Decision ID: substrate-slack +Applies to: matrices/slack.json, all actions; identity split user vs bot +Owners (sign-off roles): lfx owner, langflow-base owner, Enterprise owner, hosted-app owner, release owner +Last verified: 2026-09-01 + +## Context + +Decides whether wave-1 Slack runs mixed (official MCP server for user-identity actions, Web API for bot actions) or +Web API throughout, and how Slack's PKCE rule constrains Desktop. The governing plan requires the discovery gate to +freeze the MCP source and version plus an explicit action-to-tool mapping before implementation. The provider docs +name capabilities but not the exact tool identifiers or schemas for the four candidate user actions, and this gate +has no authenticated, dated `tools/list` capture. That evidence boundary blocks MCP—not the Slack actions—from 1.13. + +## Facts (with citations) + +| # | Fact | Source URL | Verified on | Confidence | +|---|------|------------|-------------|------------| +| 1 | Slack MCP server at `https://mcp.slack.com/mcp`, JSON-RPC 2.0 over Streamable HTTP; "Slack supports confidential OAuth for MCP clients"; user tokens | https://docs.slack.dev/ai/slack-mcp-server/ | 2026-09-01 | high | +| 2 | "Only directory-published apps or internal apps may use MCP"; workspace admins approve and manage MCP client integrations | same | 2026-09-01 | high | +| 3 | GA: Slack's blog of 2026-02-17 announces 'the general availability of Slack's Real-Time Search (RTS) API and Model Context Protocol (MCP) server', with a companion developer changelog entry the same day; the server docs page itself carries no availability label | https://slack.com/blog/news/mcp-real-time-search-api-now-available and https://docs.slack.dev/changelog/2026/02/17/slack-mcp/ | 2026-09-01 | high | +| 4 | Bot tokens are not issued through the MCP server; bot and channel actions need the Web API | same | 2026-09-01 | high | +| 5 | "Desktop redirects are not allowed to request bot scopes"; PKCE with S256; custom URI schemes and PKCE-opted localhost count as desktop | https://docs.slack.dev/authentication/using-pkce/ | 2026-09-01 | high | +| 6 | Since 2025-05-29 commercially distributed non-Marketplace apps get 1 request per minute with a 15-message cap on conversations.replies (and conversations.history) | https://docs.slack.dev/reference/methods/conversations.replies | 2026-09-01 | high | +| 8 | The server docs enumerate capabilities (search messages and files; read channel history and send messages; create, update, read canvases; fetch user info; list channel members; upload files via `slack_get_file_upload_url` and `slack_complete_file_upload`; lists) and the granular search scopes `search:read.public`, `.private`, `.mpim`, `.im`, but name tool identifiers only for file upload | https://docs.slack.dev/ai/slack-mcp-server/ | 2026-09-01 | high | +| 7 | Web API methods for every wave-1 action are GA with published tiers (search.messages Tier 2 user-token only; chat.postMessage special ~1 per second per channel; reactions.add Tier 3; conversations.members Tier 4; canvases.create Tier 2) | method pages under https://docs.slack.dev/reference/methods/ | 2026-09-01 | high | +| 9 | The MCP server page says "Slack supports confidential OAuth for MCP clients" using the app's `client_id` and `client_secret`, then adds a "Consider using PKCE" callout: "Looking to use desktop clients? PKCE support is now available!"; MCP clients must be backed by a registered Slack app with a fixed app ID; Dynamic Client Registration is not supported; user-token endpoints are `https://slack.com/oauth/v2_user/authorize` and `oauth.v2.user.access`. The PKCE page says enabling PKCE marks the app as a public client and is one-way | https://docs.slack.dev/ai/slack-mcp-server/ and https://docs.slack.dev/authentication/using-pkce/ | 2026-09-01 | high | + +## Options + +### Option A: Mixed, MCP for user-identity actions and Web API for bot actions + +Pros: exercises the strategic substrate where it is closest to production; user-identity reads on MCP are not +subject to the non-Marketplace Web API reduction (fact 6); Slack maintains the tool schemas. +Cons: two auth and error paths in one bundle; requires INT-9 pinned mode (3 engineer-weeks) in 1.13; the hosted +Langflow-owned app must be directory-published (fact 2), which is a Slack review with its own lead time; GA is now cited (fact 3); tool identifiers for the four user actions are not in the docs (fact 8). +Cost: INT-9 plus the Marketplace listing lead time for hosted. + +### Option B: Web API throughout (selected for 1.13) + +Pros: one provider API substrate, one OAuth install exchange carrying `scope` and `user_scope`, one error +normalizer, no INT-9 in 1.13; every method is GA (fact 7). +Cons: the hosted app still needs Marketplace approval or read actions fall to 1 request per minute (fact 6); the +strategic substrate is not exercised in 1.13 and the GA-swap procedure has no live example. +Cost: none beyond INT-12; INT-9 deferred to 1.14 with no sample action. + +### Option C: MCP only + +Drops every bot action (fact 4) and the Desktop bot limitation becomes moot, but the plan's hosted and self-managed +bot use cases (post as app, react, list members) disappear from wave 1. Not recommended. + +## Decision + +Option B for 1.13. Every `slack.user.*` and `slack.bot.*` action runs on the documented Slack Web API. User actions +use a user-token authorization-code profile; bot actions use the workspace-install bot-token profile and remain +unavailable on Desktop (fact 5). Hosted and self-managed registrations are confidential clients; Desktop user +actions use a Langflow-owned, PKCE-enabled public client with loopback redirect (a second Slack app, with a +customer-owned registration as the override; `decisions/desktop-oauth-ownership.md`); headless credentials are +externally provisioned. `substrate_decision.chosen` in `matrices/slack.json` is `["rest"]`. + +The strategic MCP path is deferred to the 1.14 planning gate. It may replace a REST action only after a dated +`tools/list` capture records the server URL/version, exact tool identifier, input schema, output schema, and +authorization exchange for that action. A future adoption changes this decision and the matrix before component +implementation; INT-9 is not a post-gate discovery task for 1.13. + +## Consequences + +- INT-9 moves to 1.14; INT-12 has no MCP implementation dependency in 1.13. +- The hosted Slack app still needs a Slack Marketplace listing to avoid the reduced + `conversations.replies` rate tier; the estimate records it as calendar risk. +- Desktop hides bot actions in both options (fact 5). +- Desktop Slack user actions use a second, Langflow-owned, PKCE-enabled Slack app; because PKCE opt-in is one-way + and marks the app public (fact 9), it cannot share a registration with the confidential-client hosted install. A + customer-owned PKCE app remains the override (`decisions/desktop-oauth-ownership.md`). INT-5 records client type + and owner per context through the named OAuth profiles in `connection-contract.md` section 8. + +## Re-open trigger + +- A dated authenticated `tools/list` capture freezes exact identifiers and schemas for candidate actions, or +- Slack documents those identifiers and schemas itself, or +- Slack changes the directory-published requirement or the MCP server's availability status. + +Re-verify by: the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| lfx owner | | | | +| langflow-base owner | | | | +| Enterprise owner | | | | +| hosted-app owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/estimate.md b/design/dedicated-integrations/estimate.md new file mode 100644 index 000000000000..a60c788edb7e --- /dev/null +++ b/design/dedicated-integrations/estimate.md @@ -0,0 +1,56 @@ +# Re-issued estimate for INT-1 through INT-14 + +Status: re-issued 2026-09-01 under the release owner's confirmed decisions; amended 2026-09-02 for the Desktop registration decision +Owners (sign-off roles): release owner +Last verified: 2026-09-02 + +The gate's last exit criterion is a re-issued estimate. The original ticket breakdown summed to 49 +engineer-weeks including INT-1. The numbers below apply the gate's findings ticket by ticket; every delta names the +decision or fact that caused it. Assumptions: one engineer per stream; INT-10, INT-11, INT-12 run in parallel once +INT-3 and INT-5 land; the accepted decisions in `decisions/` hold (Google sdk, Microsoft rest, Slack rest, hosted +Google app avoids restricted scopes, KB connectors adopt the contract in 1.13 per the release owner's 2026-09-01 decision, Desktop uses Langflow-owned +public clients per the release owner's 2026-09-02 decision). + +## Per ticket + +| Ticket | Original | Re-issued | Delta | Why | +|---|---|---|---|---| +| INT-1 Discovery gate | 3 | 3 | 0 | as sized; this PR | +| INT-2 lfx connection contract | 3 | 3.5 | +0.5 | `ExecutionPrincipal` type and the `connection_resolution` matrix dimension are more than the ticket text; the single env resolver is less (`connection-contract.md` sections 3 to 5) | +| INT-3 Manifest `integrations` field | 1.5 | 1.5 | 0 | as designed; capability ids are the matrices' `action_id`s | +| INT-4 Connection entity and API | 4 | 4.5 | +0.5 | per-connection `allow_non_interactive` flag, connection as a share resource type, `required_connections` in the artifact builder, HKDF envelope (contract section 12.b) | +| INT-5 OAuth broker | 4 | 5.25 | +1.25 | two registration modes (Langflow-owned for hosted and Desktop, customer-owned for self-managed and as the Desktop override) times three providers; Desktop loopback on Langflow-owned public clients, including a second PKCE-enabled Slack app and the Desktop entries in the named OAuth profiles (`decisions/desktop-oauth-ownership.md`, +0.25); cross-worker single-flight refresh; Microsoft rotating refresh tokens and Slack optional rotation are two refresh behaviors | +| INT-6 Executing identity | 3 | 3 | 0 | the allow/deny table is already written per family in the contract | +| INT-7 Governance | 3 | 3 | 0 | mirrors the model-provider policy pattern and includes the operator policy panel in `frontend-surfaces.md` B9 | +| INT-8 Frontend Connections UX | 5 | 6 | +1 | OAuth return handling (popup plus `postMessage` or callback route) is greenfield; scope-coverage picker; a11y baseline spec; i18n in seven locales (`frontend-surfaces.md` B3, B5, A14) | +| INT-9 MCP pinned mode | 3 | 0 | -3 | deferred to 1.14 because the gate has no dated authenticated `tools/list` evidence to freeze exact Slack tool ids and schemas (`decisions/substrate-slack.md`) | +| INT-10 lfx-google wave 1 | 5 | 4.75 | -0.25 | include set shrinks to five SDK actions with no restricted scope and no MCP (-1); the `GoogleOAuthToken` deprecation and upgrade-checker rule remain; KB Drive ingestion source on connections (+0.75, `decisions/kb-oauth-connector-adoption.md`) | +| INT-11 lfx-microsoft | 5 | 5.75 | +0.75 | eight Graph actions, a new bundle's eight registration points, the Entra guide; KB OneDrive, SharePoint, and Graph ingestion sources on connections plus the KB connector picker (`decisions/kb-oauth-connector-adoption.md`) | +| INT-12 lfx-slack | 4 | 3 | -1 | seven Web API actions share one SDK and error-normalization path; separate named user OAuth and bot-install profiles remain | +| INT-13 Headless reference | 1.5 | 1.5 | 0 | the env resolver is the sample | +| INT-14 GA validation | 4 | 4 | 0 | contexts reduce to two callback paths times two client types, offset by three providers' verification runbooks | +| **Total** | **49** | **48.75** | **-0.25** | inside the plan's 45 to 55 working range | + +Sensitivity: adopting Slack MCP in 1.14 adds the deferred INT-9 estimate (3 engineer-weeks) plus any action-specific +migration work established by the required `tools/list` capture. Accepting CASA instead of avoiding restricted +scopes adds no engineer-weeks to INT-10 but adds several weeks of calendar lead time and an annual recurring +assessment that no ticket currently carries. + +## External lead times (calendar risk, not engineer-weeks) + +| Dependency | Context | Lead time | Source | +|---|---|---|---| +| Google brand verification | hosted | typically 2 to 3 business days | `matrices/google.json` sources `google-restricted-scope-verification` | +| Google sensitive-scope verification (`gmail.send`, Calendar scopes) | hosted | typically 3 to 5 business days | `google-sensitive-scope-verification` | +| Google CASA | hosted, only if the restricted-scope decision flips to accept | several weeks, then annual | `google-restricted-scope-verification` | +| Microsoft publisher verification | hosted | minutes once a verified Cloud Partner Program account exists; obtaining and verifying that account is the real lead time | `matrices/microsoft.json` source `entra-publisher-verification` | +| Slack Marketplace listing | hosted, required to lift the non-Marketplace `conversations.replies` rate reduction | Slack review; weeks, not documented | `matrices/slack.json` sources `slack-conversations-replies`, `slack-rate-limits` | +| Google Desktop client and Entra desktop platform on the hosted registrations | desktop | none beyond hosted: Google verification is per project consent screen and Microsoft publisher verification is per registration, so Desktop inherits both | `decisions/desktop-oauth-ownership.md` facts 2 and 4 | +| Second Langflow-owned Slack app (PKCE, Desktop) | desktop | none to create; the Marketplace listing question is shared with hosted because any distributed non-Marketplace app runs `conversations.replies` at the reduced tier | `decisions/desktop-oauth-ownership.md` facts 5 and 6 | +| Slack MCP tool identifiers | deferred 1.14 track | authenticated `tools/list` capture before any action is moved from REST to MCP | `decisions/substrate-slack.md` | + +## What the estimate does not include + +Triggers and webhooks (`triggers-deferred.md`); OAuth for unauthenticated public-flow callers; a self-managed restricted-scope profile +(`decisions/google-restricted-scopes.md` Option C); Enterprise approvals, retention, and audit query UI beyond the +existing plugin seams. diff --git a/design/dedicated-integrations/frontend-surfaces.md b/design/dedicated-integrations/frontend-surfaces.md new file mode 100644 index 000000000000..e3e9aaedb9b1 --- /dev/null +++ b/design/dedicated-integrations/frontend-surfaces.md @@ -0,0 +1,75 @@ +# Frontend surface list for Dedicated Integrations (1.13) + +Status: draft +Owners (sign-off roles): frontend owner, release owner +Last verified: 2026-09-01 against `release-1.12.0` + +This is the gate's exit criterion "frontend surface list". Every surface is tagged with the ticket that owns it and +whether it is an extension of something that exists or net new. Paths are under `src/frontend/src/`. + +## Surfaces that exist and need extension + +| # | Surface | Where | Work | Ticket | +|---|---|---|---|---| +| A1 | Settings navigation and route | `pages/SettingsPage/index.tsx` (`sidebarNavItems`), `routes.tsx` (``) | one nav entry `/settings/connections` and one ``; `settings.nav.connections` in all 7 `src/locales/*.json` | INT-8 | +| A2 | Node header connect button | `CustomNodes/GenericNode/components/NodeStatus/index.tsx` | scans template fields with `type === "auth"`; hard-codes a Composio `api_key`/`COMPOSIO_API_KEY` precondition and a 21 s polling cap; generalize to `connection_ref` state and show "connected as" | INT-8 | +| A3 | In-field connect widget | `components/core/parameterRenderComponent/components/connectionComponent/index.tsx`, `customization/components/custom-connectionComponent.tsx`, dispatch `case "connect"` in `parameterRenderComponent/index.tsx` | keep for Composio; add a sibling `case "connection_ref"` renderer rather than changing `connect` semantics; the 9 s polling cap cannot survive a real consent screen | INT-8 | +| A4 | Action pickers | `sortableListComponent/`, `actionPickerComponent/`, `ListSelectionComponent/` | reuse for per-action selection driven by `search_category`; no change expected | INT-10 to INT-12 | +| A5 | Dynamic field refresh | `CustomNodes/helpers/mutate-template.ts`, `controllers/API/queries/nodes/use-post-template-value.ts`, `use-handle-new-value.ts`, `use-fetch-data-on-mount.ts` | no change; `update_build_config`, `refresh_button`, `real_time_refresh` already work | none | +| A6 | Secret input and global-variable picker | `parameterRenderComponent/components/inputGlobalComponent/`, `components/core/GlobalVariableModal/GlobalVariableModal.tsx` | reuse for API-key-mode connectors; add a read-only "managed by connection" state for fields a connection supersedes | INT-8 | +| A7 | Sidebar catalog | `utils/styleUtils.ts` (`SIDEBAR_CATEGORIES` line 314, `SIDEBAR_BUNDLES` line 418), `pages/FlowPage/components/flowSidebarComponent/components/sidebar-nav-items.ts`, `categoryGroup.tsx` | add `Microsoft 365` and `Slack` bundle groups and fold the existing `Gmail` group into `Google` per `decisions/palette-naming.md` (`GmailLoaderComponent` is re-grouped only); `McpSidebarGroup.tsx` is the template for a group with an empty state and an add modal | INT-11, INT-12 | +| A8 | Icon registry | `icons/lazyIconImports.ts`, `icons/eagerIconImports.ts` | `Gmail`, `GoogleDrive`, `Googlecalendar` exist; add `Microsoft`, `Slack`, `Teams`, `Outlook`, `OneDrive`, `SharePoint` | INT-11, INT-12 | +| A9 | Feature gating | `customization/feature-flags.ts` | `ENABLE_INTEGRATIONS = false` is declared and referenced nowhere; use it, mirrored at runtime through `GET /api/v1/config` like `enable_extension_reload` | INT-8 | +| A10 | OAuth field layout | `modals/authModal/index.tsx` | the richest OAuth form in the codebase, but it configures Langflow as an OAuth *server* (project MCP); harvest layout and the host/port to callback-URL derivation, do not reuse the component | INT-8 | +| A11 | MCP server headers | `modals/addMcpServerModal/index.tsx` (`IOKeyPairInputWithVariables`) | already binds header values to global variables; the path for hand-configured token auth to remote MCP servers stays as is | none | +| A12 | Provider master-detail shell | `modals/modelProviderModal/components/ModelProvidersContent.tsx`, `ProviderList.tsx`, `ProviderListItem.tsx`, `ProviderConfigurationForm.tsx`, `DisconnectWarning.tsx`, `hooks/useProviderConfiguration.ts` | the left-list, right-config layout the Connections page wants; the `validationState` machine and Disconnect warning are reusable; note it writes keys into global variables through `usePostGlobalVariables`, which connections must not do | INT-8 | +| A13 | Account CRUD precedent | `pages/MainPage/pages/deploymentsPage/components/add-provider-modal.tsx`, `provider-credentials-form.tsx`, `providers-table.tsx`, `connection-search-list.tsx`; hooks under `controllers/API/queries/deployment-provider-accounts/` | closest structural analogue for a per-account list and add or edit modal; API-key only, deployment-target scoped | INT-8 | +| A14 | E2E harness | `tests/utils/go-to-settings.ts`, `tests/utils/open-add-mcp-server-modal.ts`, `tests/utils/seed-loopback-provider.ts`, `tests/core/features/composio.spec.ts`, `tests/a11y/*.a11y.spec.ts` with baselines | extend `navigateSettingsPages`; add `connections.a11y.spec.ts` plus baseline; the Composio spec (injects a fake `AuthInput` component, asserts `button_connected_gmail`) is the template for a connector node test | INT-8, INT-14 | + +## Surfaces that are net new + +| # | Surface | Why nothing exists | Ticket | +|---|---|---|---| +| B1 | `/settings/connections` page: per-user connected accounts with provider, account identity, granted scopes, status, connected-at, reconnect, revoke; operator view of instance connections | no page models a user's third-party account; `GlobalVariablesPage` stores opaque secrets and `ProviderAccount` is deployment-target scoped | INT-8 | +| B2 | Connect-account modal and provider catalog picker (choose provider, see requested scopes, start consent) | `authModal` configures Langflow as an OAuth server, not as a client | INT-8 | +| B3 | OAuth return handling: popup with an origin-checked `postMessage` or a callback route with a `window.closed` watchdog and blocked-popup fallback; the Desktop loopback case on `localhost:7860` | the only mechanism today is `customOpenNewTab` plus `mutateTemplate` polling capped at 21 s; no `/oauth/callback` route, no `postMessage` OAuth (all `postMessage` hits are AudioWorklet in `voice-assistant/`) | INT-8 | +| B4 | Connections Zustand store and `controllers/API/queries/connections/` (`useGetConnections`, `usePostConnection`, `useDeleteConnection`, `useTestConnection`) with cache invalidation on connect | `src/stores/` has no connection store; the only `connection` matches in `flowStore.ts` are ReactFlow edges | INT-8 | +| B5 | Per-field connection status: "connected as", scope-missing warning against `required_scopes`, expired-token re-auth call to action keyed on the typed error `code` | fields express only `validated`, `error`, or a URL; no identity, scope, or expiry surface | INT-8 | +| B6 | Token-expiry and re-consent notification through `alertStore` | no expiry concept exists; nothing feeds token lifecycle events | INT-8 | +| B7 | Connection choice per node when a user has two accounts for one provider | global variables have `PermissionsProvider` sharing but no account-selection-per-node concept | INT-8 | +| B8 | Connectors sidebar section with live connection state (grey out unconnected providers, inline connect) | `SIDEBAR_BUNDLES` entries are static records with no runtime binding; `extension_id` is not populated, which is also why bundle reload is flagged off | INT-8 (MVP: none; defer to 1.14) | +| B9 | Operator integration-policy panel within `/settings/connections`: provider enable/disable, allowed capability ids, allowed connection owner kinds, and hosted-registration readiness; hidden from non-operators and backed by the governance service rather than frontend-only flags | no current settings surface exposes provider/capability policy; the model-provider policy precedent is API and plugin wiring, not a reusable integration-policy UI | INT-7 | + +## Constraints the design must record + +1. The connect flow today is new-tab-plus-polling with 21 s and 9 s caps. A real Google or Microsoft consent screen + (account chooser, scope grant, possible MFA) routinely exceeds both. Either B3 is built or the polling budget and + its timed-out state are redesigned; silently reverting to disconnected after 9 s reads as a bug. +2. Every credential today lands in the global-variables table (`ModelProvidersPage`, `DBProvidersPage`, and the + node-level secret picker all write through `usePostGlobalVariables`). Connections get their own store (B4) and + must not appear in `/settings/global-variables`; the design says so explicitly so users do not see unexplained + rows and so existing sharing tooling is not assumed to cover them. +3. Desktop (Tauri) runs the backend on `localhost:7860`, so the OAuth return is the same callback route as + self-managed with a public client and loopback redirect; the frontend needs no Tauri-specific bridge for the + return, only for opening the system browser. Desktop defaults to Langflow-owned public clients, so the connect + flow has no registration step; the customer-owned registration form is the one self-managed uses and is the + override (`decisions/desktop-oauth-ownership.md`). Slack's PKCE opt-in marks the app a public client, one-way, + so Desktop Slack uses a second, Langflow-owned, PKCE-enabled Slack app (`decisions/substrate-slack.md` fact 9). +4. A11y is a review gate here: every settings page ships an axe baseline spec, and `NodeStatus`, + `GlobalVariablesPage`, and `MCPServersPage` carry explicit WCAG comments. B1, B2, and B5 need keyboard-only paths. +5. B9 is an operator control, not a substitute for backend enforcement. The API returns effective policy and rejects + forbidden provider, capability, owner-kind, and registration-mode combinations; hiding a control in React is + only presentation. + +## MVP versus defer + +MVP for 1.13: A1, A2, A3 (sibling renderer), A6, A7, A8, A9, A12 (layout reuse), A14, B1, B2, B3, B4, B5, B9. +Defer: B6 (surface expiry only through B5 at first), B7 (one connection per provider per user in wave 1; a second +account becomes a second named connection selectable in the picker), B8. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| frontend owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/design/dedicated-integrations/matrices/google.json b/design/dedicated-integrations/matrices/google.json new file mode 100644 index 000000000000..770fa4e22ab6 --- /dev/null +++ b/design/dedicated-integrations/matrices/google.json @@ -0,0 +1,926 @@ +{ + "schema_version": 1, + "provider": "google", + "display_name": "Google Workspace", + "bundle": { + "extension_id": "lfx-google", + "bundle_name": "google", + "distribution": "lfx-google" + }, + "wave": 1, + "max_included_actions": 8, + "verified_on": "2026-09-01", + "oauth_app_owner_by_context": { + "hosted": "langflow", + "self_managed": "customer", + "desktop": "langflow", + "headless": "customer" + }, + "oauth_client_type_by_context": { + "hosted": "confidential", + "self_managed": "confidential", + "desktop": "public", + "headless": "external" + }, + "substrate_decision": { + "chosen": [ + "sdk" + ], + "decision_record": "decisions/substrate-google.md" + }, + "restricted_scope_decisions": [ + { + "scope": "https://www.googleapis.com/auth/gmail.readonly", + "decision": "avoid", + "rationale": "Release owner decision 2026-09-01 (decisions/google-restricted-scopes.md Option B): the Langflow-owned hosted app requests no restricted scope in 1.13. gmail.readonly is the narrowest scope that supports users.messages.list with a q filter plus users.messages.get for bodies; gmail.metadata is restricted as well and cannot be used with q, so it is not a narrower alternative for search.", + "decision_record": "decisions/google-restricted-scopes.md" + }, + { + "scope": "https://www.googleapis.com/auth/drive.readonly", + "decision": "avoid", + "rationale": "Release owner decision 2026-09-01 (decisions/google-restricted-scopes.md Option B): the Langflow-owned hosted app requests no restricted scope in 1.13. drive.readonly is needed to fetch files the app did not create and the user did not pick; drive.file (non-sensitive) is the alternative and limits fetch to app-created or user-picked files.", + "decision_record": "decisions/google-restricted-scopes.md" + }, + { + "scope": "https://www.googleapis.com/auth/drive.metadata.readonly", + "decision": "avoid", + "rationale": "Release owner decision 2026-09-01 (decisions/google-restricted-scopes.md Option B): the Langflow-owned hosted app requests no restricted scope in 1.13. Listing arbitrary Drive files needs a metadata scope beyond drive.file; drive.file-only listing returns only files the app created or the user opened with it.", + "decision_record": "decisions/google-restricted-scopes.md" + } + ], + "sources": { + "gmail-scopes": { + "url": "https://developers.google.com/workspace/gmail/api/auth/scopes", + "title": "Gmail API: Choose Gmail API scopes", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Table classifies gmail.labels and the add-on compose/action scopes as non-sensitive; gmail.send and the add-on metadata/readonly scopes as sensitive; mail.google.com, gmail.readonly, gmail.compose, gmail.insert, gmail.modify, gmail.metadata, gmail.settings.* as restricted." + }, + "drive-scopes": { + "url": "https://developers.google.com/workspace/drive/api/guides/api-specific-auth", + "title": "Google Drive API: Choose Google Drive API scopes", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "drive.file, drive.appdata, drive.install are non-sensitive; drive.apps.readonly is sensitive; drive, drive.readonly, drive.metadata, drive.metadata.readonly, drive.activity*, drive.meet.readonly, drive.scripts are restricted. 'If you store restricted scope data on servers (or transmit), then you must go through a security assessment.'" + }, + "calendar-scopes": { + "url": "https://developers.google.com/workspace/calendar/api/auth", + "title": "Google Calendar API: Choose Calendar API scopes", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Lists every Calendar scope with its consent-screen text; the page itself carries no classification column." + }, + "google-sensitive-scope-verification": { + "url": "https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification", + "title": "Google Identity: Sensitive scope verification", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Names 'reading events stored in Google Calendar' as an example of a sensitive scope. 'The sensitive scope verification process typically takes 3-5 business days to complete.' Exemptions: personal use, testing projects, service-account-only data, Internal Workspace/Cloud Identity use, domain-wide installation." + }, + "google-restricted-scope-verification": { + "url": "https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification", + "title": "Google Identity: Restricted scope verification (CASA)", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Brand verification first (typically 2-3 business days). Security assessment under the App Defense Alliance CASA framework by Google-empanelled assessors is mandatory for apps that access restricted data from or through a third-party server; it 'can potentially take several weeks to complete'; apps must be reverified at least every 12 months after the Letter of Assessment date. Unverified apps face a user cap and an unverified-app screen. Internal user type projects owned by the organization are exempt from verification but may need admin approval." + }, + "google-oauth-app-audience": { + "url": "https://support.google.com/cloud/answer/15549945", + "title": "Google Cloud: Manage OAuth app audience (user type and publishing status)", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Internal user type is available only to projects in a Google Cloud Organization and limits authorization to organization members. Testing publishing status is limited to 100 test users and authorizations expire seven days from consent, including refresh tokens." + }, + "google-oauth-verification-requirements": { + "url": "https://support.google.com/cloud/answer/13464321", + "title": "Google Cloud: OAuth app verification requirements", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Brand verification for all apps; use-case justification and demonstration video for sensitive and restricted scopes; annual external security assessment for restricted scopes." + }, + "google-oauth2-overview": { + "url": "https://developers.google.com/identity/protocols/oauth2", + "title": "Google Identity: Using OAuth 2.0 to access Google APIs", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Refresh token expiration reasons, the 7-day Testing expiry, the limit of 100 refresh tokens per Google Account per OAuth client, and incremental authorization guidance." + }, + "google-oauth2-web-server": { + "url": "https://developers.google.com/identity/protocols/oauth2/web-server", + "title": "Google Identity: OAuth 2.0 for web server applications", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Revoke endpoint https://oauth2.googleapis.com/revoke; access_type=offline; refresh_token only returned on first authorization unless prompt=consent; redirect URIs must use https except localhost and loopback IPs." + }, + "google-oauth2-native-app": { + "url": "https://developers.google.com/identity/protocols/oauth2/native-app", + "title": "Google Identity: OAuth 2.0 for mobile and desktop apps", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Desktop app client type; client secret is not treated as confidential; PKCE recommended; loopback redirect http://127.0.0.1:port or http://[::1]:port with a random available port; custom URI schemes and the OOB copy/paste flow are no longer supported." + }, + "workspace-admin-app-access": { + "url": "https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data", + "title": "Google Workspace Admin Help: Control which third-party and internal apps access Google Workspace data", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Access levels Trusted, Limited, Specific Google data, Blocked; Limited apps cannot reach services an admin marks Restricted; by default users can sign in with Google to any third-party app; 'Trust internal apps' allows apps from the organization's own Cloud project to access restricted Workspace APIs." + }, + "google-workspace-mcp": { + "url": "https://developers.google.com/workspace/guides/configure-mcp-servers", + "title": "Google Workspace: Configure MCP servers (Developer Preview)", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Eight remote servers (Gmail https://gmailmcp.googleapis.com/mcp/v1, Drive https://drivemcp.googleapis.com/mcp/v1, Docs, Sheets, Slides, Calendar https://calendarmcp.googleapis.com/mcp/v1, Chat, People). Users bring their own Google Cloud OAuth client. Gmail server requests gmail.readonly and gmail.compose; Drive server requests drive.readonly and drive.file. No rate limits or GA timeline stated." + }, + "google-workspace-preview-program": { + "url": "https://developers.google.com/workspace/preview", + "title": "Google Workspace Developer Preview Program", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Preview features 'may not be included in public applications prior to the General Availability (GA) announcement' and end users outside the developer's own domain may not be granted access before GA; pre-GA APIs are provided as is with no guarantee of reaching GA; features usually stay in preview 3-6 months." + }, + "gmail-quota": { + "url": "https://developers.google.com/workspace/gmail/api/reference/quota", + "title": "Gmail API: Usage limits", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Per project 1,200,000 quota units per minute; per user per project 6,000 quota units per minute; daily billing threshold 80,000,000 units per project; messages.send 100 units, messages.list 5 units, messages.get 20 units; 500 recipients per message. Quotas updated 2026-05-01." + }, + "drive-limits": { + "url": "https://developers.google.com/workspace/drive/api/guides/limits", + "title": "Google Drive API: Usage limits", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Per project 1,000,000 quota units per minute; per user per project 325,000 quota units per minute; files.get 5 units, files.list 100 units, download 200 units; 403 'User rate limit exceeded' and 429 'Rate limit exceeded'; truncated exponential backoff." + }, + "calendar-quota": { + "url": "https://developers.google.com/workspace/calendar/api/guides/quota", + "title": "Google Calendar API: Manage quotas", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Per project 10,000 requests per minute; per user per project 600 requests per minute; daily billing threshold 1,000,000 requests; 403 or 429 usageLimits on exceed; the Calendar MCP server shares the same per-minute allocation as query cost." + }, + "gmail-messages-send": { + "url": "https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send", + "title": "Gmail API: users.messages.send", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST https://gmail.googleapis.com/gmail/v1/users/{userId}/messages/send (or /upload/...). Body: Message with raw base64url RFC 2822, optional threadId and labelIds. Response: Message with id, threadId, labelIds. Scopes: mail.google.com, gmail.modify, gmail.compose, gmail.send." + }, + "gmail-messages-list": { + "url": "https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/list", + "title": "Gmail API: users.messages.list", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET .../users/{userId}/messages with q, labelIds, maxResults (default 100, max 500), pageToken, includeSpamTrash. Returns messages[] {id, threadId}, nextPageToken, resultSizeEstimate. Scopes: mail.google.com, gmail.modify, gmail.readonly, gmail.metadata; q 'cannot be used when accessing the api using the gmail.metadata scope'." + }, + "drive-files-list": { + "url": "https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list", + "title": "Google Drive API: files.list", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET https://www.googleapis.com/drive/v3/files with q, pageSize (max 1000), pageToken, corpora, driveId, includeItemsFromAllDrives, supportsAllDrives, orderBy, spaces, fields. Returns files[], nextPageToken, incompleteSearch. Scopes include drive, drive.file, drive.metadata, drive.metadata.readonly, drive.readonly; 'Some scopes are restricted and require a security assessment'." + }, + "drive-files-get": { + "url": "https://developers.google.com/workspace/drive/api/reference/rest/v3/files/get", + "title": "Google Drive API: files.get", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET https://www.googleapis.com/drive/v3/files/{fileId}; alt=media downloads content; acknowledgeAbuse; supportsAllDrives. Google Docs, Sheets, Slides must be exported with files.export instead. Same scope list as files.list." + }, + "calendar-events-list": { + "url": "https://developers.google.com/workspace/calendar/api/v3/reference/events/list", + "title": "Google Calendar API: Events.list", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET .../calendars/{calendarId}/events; 'primary' for the user's primary calendar; timeMin, timeMax, q, maxResults (default 250, max 2500), singleEvents, orderBy, pageToken, syncToken, showDeleted. Returns items[], nextPageToken, nextSyncToken, timeZone. Scopes include calendar.readonly, calendar, calendar.events.readonly, calendar.events, calendar.events.owned(.readonly), calendar.events.freebusy, calendar.events.public.readonly, calendar.app.created." + }, + "calendar-events-insert": { + "url": "https://developers.google.com/workspace/calendar/api/v3/reference/events/insert", + "title": "Google Calendar API: Events.insert", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST .../calendars/{calendarId}/events; sendUpdates, conferenceDataVersion, supportsAttachments, maxAttendees; required start and end; optional summary, description, attendees[], location, reminders, recurrence[], conferenceData. Returns an Events resource. Scopes: calendar, calendar.events, calendar.app.created, calendar.events.owned." + } + }, + "verification_programs": { + "google-brand-verification": { + "description": "Brand verification of the OAuth consent screen (app name, logo, homepage, privacy policy, domain ownership via Search Console). Required for any External app before sensitive or restricted verification; typically 2-3 business days.", + "source": "google-restricted-scope-verification", + "blocking_for_contexts": [ + "hosted" + ], + "lead_time": "2-3 business days" + }, + "google-sensitive-scope-verification": { + "description": "Google review of each requested sensitive scope with a written justification and a demonstration video. Required for the Langflow-owned External hosted app. A self-managed customer whose project uses the Internal user type is exempt; a self-managed External project must complete it itself.", + "source": "google-sensitive-scope-verification", + "blocking_for_contexts": [ + "hosted" + ], + "lead_time": "3-5 business days" + }, + "google-casa": { + "description": "Cloud Application Security Assessment under the App Defense Alliance framework by a Google-empanelled assessor, mandatory when restricted-scope data is accessed from or through a third-party server (a hosted Langflow backend is one). Annual reverification within 12 months of the Letter of Assessment. Internal user type projects are exempt from verification; app verification is still required for domain-wide installations that use restricted or sensitive scopes.", + "source": "google-restricted-scope-verification", + "blocking_for_contexts": [ + "hosted" + ], + "lead_time": "several weeks", + "recurring": "every 12 months" + }, + "google-workspace-developer-preview": { + "description": "Membership in the Workspace Developer Preview Program is required to use the Workspace MCP servers, and the program terms forbid including preview features in public applications or granting end users outside the developer's domain access before the GA announcement. Blocks every deployment context while the servers remain pre-GA.", + "source": "google-workspace-preview-program", + "blocking_for_contexts": [ + "hosted", + "self_managed", + "desktop", + "headless" + ] + } + }, + "actions": [ + { + "action_id": "google.gmail.send", + "display_name": "Gmail: Send Email", + "component_class": "GmailSendComponent", + "decision": "include", + "rationale": "Highest-demand write action. Needs only gmail.send, which Google classifies as sensitive, so it does not depend on the CASA decision. Included in wave 1 by the release owner on 2026-09-01.", + "confidence": "high", + "schema": { + "inputs": [ + { + "name": "to", + "type": "list[str]", + "required": true, + "description": "Recipient addresses" + }, + { + "name": "cc", + "type": "list[str]", + "required": false + }, + { + "name": "bcc", + "type": "list[str]", + "required": false + }, + { + "name": "subject", + "type": "str", + "required": true + }, + { + "name": "body", + "type": "str", + "required": true, + "description": "Plain text or HTML body" + }, + { + "name": "body_is_html", + "type": "bool", + "required": false + }, + { + "name": "attachments", + "type": "list[File]", + "required": false, + "description": "Sent via the /upload variant when present" + }, + { + "name": "thread_id", + "type": "str", + "required": false, + "description": "Reply into an existing thread" + } + ], + "outputs": [ + { + "name": "message", + "type": "Data", + "required": true, + "description": "users.messages.send response: id, threadId, labelIds" + } + ], + "source": "gmail-messages-send" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Sends from the connected user's own mailbox (userId=me). Sending as another user requires domain-wide delegation, which is excluded from wave 1.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/gmail.send", + "classification": "sensitive", + "provider_classification": "Sensitive", + "source": "gmail-scopes", + "role": "required", + "notes": "'Send email on your behalf.'" + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "The connected user's own mailbox; can address any recipient, up to 500 recipients per message.", + "tenant": "Single user. No cross-user or organization-wide reach without domain-wide delegation.", + "source": "gmail-messages-send" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client users().messages().send() (GA). The Workspace Gmail MCP server (Developer Preview) requests gmail.compose, which is restricted, and not gmail.send, so an MCP-backed send would raise this action's scope tier and trigger CASA.", + "substrate_source": "gmail-messages-send", + "rate_limit": { + "summary": "Per user per project 6,000 quota units per minute and per project 1,200,000 per minute; messages.send costs 100 units, so about 60 sends per user per minute; 500 recipients per message; daily billing threshold 80,000,000 units per project.", + "source": "gmail-quota", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification", + "google-sensitive-scope-verification" + ] + }, + { + "action_id": "google.gmail.search", + "display_name": "Gmail: Search Messages", + "component_class": "GmailSearchComponent", + "decision": "exclude", + "rationale": "Excluded from wave 1 by the release owner on 2026-09-01: gmail.readonly is restricted and the hosted app avoids restricted scopes; gmail.metadata is also restricted and rejects the q parameter, so there is no narrower search scope. Carried to the 1.14 candidate list under the self-managed restricted-scope profile option.", + "confidence": "high", + "open_questions": [ + "1.14: size the self-managed restricted-scope profile (decisions/google-restricted-scopes.md Option C)." + ], + "schema": { + "inputs": [ + { + "name": "query", + "type": "str", + "required": true, + "description": "Gmail search box syntax, passed as q" + }, + { + "name": "max_results", + "type": "int", + "required": false, + "description": "Default 100, maximum 500" + }, + { + "name": "label_ids", + "type": "list[str]", + "required": false + }, + { + "name": "include_spam_trash", + "type": "bool", + "required": false + }, + { + "name": "page_token", + "type": "str", + "required": false + }, + { + "name": "fetch_bodies", + "type": "bool", + "required": false, + "description": "When true, follow up with users.messages.get per id" + } + ], + "outputs": [ + { + "name": "messages", + "type": "list[Data]", + "required": true, + "description": "id, threadId, and fetched message fields" + }, + { + "name": "next_page_token", + "type": "str", + "required": false + }, + { + "name": "result_size_estimate", + "type": "int", + "required": false + } + ], + "source": "gmail-messages-list" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Searches only the connected user's mailbox.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/gmail.readonly", + "classification": "restricted", + "provider_classification": "Restricted", + "source": "gmail-scopes", + "role": "required", + "notes": "'View your email messages and settings.' Needed for messages.list with q and for messages.get bodies." + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs. Restricted Gmail scopes are additionally subject to the admin's Restricted-service setting: Limited apps cannot reach them.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "Every message in the connected user's mailbox, including bodies when fetched.", + "tenant": "Single user.", + "source": "gmail-scopes" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client users().messages().list() plus get(). The Workspace Gmail MCP server (Developer Preview) requests the same gmail.readonly scope, so MCP does not change the scope tier for this action.", + "substrate_source": "gmail-messages-list", + "rate_limit": { + "summary": "messages.list costs 5 quota units and each messages.get 20; per user per project 6,000 units per minute, per project 1,200,000 per minute.", + "source": "gmail-quota", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification", + "google-casa" + ] + }, + { + "action_id": "google.drive.list", + "display_name": "Drive: List Files (app files)", + "component_class": "GoogleDriveListComponent", + "decision": "include", + "rationale": "Ships on drive.file only (non-sensitive): lists files the app created or the user opened with it. Restricted-scope reach is deferred per decisions/google-restricted-scopes.md. Included in wave 1 by the release owner on 2026-09-01.", + "confidence": "high", + "schema": { + "inputs": [ + { + "name": "query", + "type": "str", + "required": false, + "description": "Drive q syntax" + }, + { + "name": "page_size", + "type": "int", + "required": false, + "description": "Maximum 1000" + }, + { + "name": "page_token", + "type": "str", + "required": false + }, + { + "name": "order_by", + "type": "str", + "required": false + }, + { + "name": "include_shared_drives", + "type": "bool", + "required": false, + "description": "Sets includeItemsFromAllDrives and supportsAllDrives" + }, + { + "name": "fields", + "type": "str", + "required": false, + "description": "Partial response selector" + } + ], + "outputs": [ + { + "name": "files", + "type": "list[Data]", + "required": true, + "description": "File resources" + }, + { + "name": "next_page_token", + "type": "str", + "required": false + }, + { + "name": "incomplete_search", + "type": "bool", + "required": false + } + ], + "source": "drive-files-list" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Lists files visible to the connected user, including shared drives when requested.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/drive.file", + "classification": "non_sensitive", + "provider_classification": "Non-sensitive (recommended)", + "source": "drive-scopes", + "role": "required", + "notes": "Wave 1 requests drive.file only; the restricted scope alternative is recorded in decisions/google-restricted-scopes.md." + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "Metadata of files the app created or the user opened with it.", + "tenant": "Single user; shared drives the user belongs to when includeItemsFromAllDrives is set.", + "source": "drive-files-list" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client files().list(). The Workspace Drive MCP server (Developer Preview) requests drive.readonly and drive.file, so MCP would not avoid the restricted tier either.", + "substrate_source": "drive-files-list", + "rate_limit": { + "summary": "files.list costs 100 quota units; per user per project 325,000 units per minute, per project 1,000,000 per minute; 403 'User rate limit exceeded' or 429 on exceed, truncated exponential backoff recommended.", + "source": "drive-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification" + ] + }, + { + "action_id": "google.drive.fetch", + "display_name": "Drive: Fetch File (app files)", + "component_class": "GoogleDriveFetchComponent", + "decision": "include", + "rationale": "Ships on drive.file only (non-sensitive): fetches files the app created or the user opened with it. Restricted-scope reach is deferred per decisions/google-restricted-scopes.md. Included in wave 1 by the release owner on 2026-09-01.", + "confidence": "high", + "schema": { + "inputs": [ + { + "name": "file_id", + "type": "str", + "required": true + }, + { + "name": "export_mime_type", + "type": "str", + "required": false, + "description": "Uses files.export for Docs, Sheets, Slides" + }, + { + "name": "acknowledge_abuse", + "type": "bool", + "required": false + }, + { + "name": "supports_all_drives", + "type": "bool", + "required": false + } + ], + "outputs": [ + { + "name": "file", + "type": "Data", + "required": true, + "description": "File metadata plus content (alt=media) or exported bytes" + } + ], + "source": "drive-files-get" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads a file the connected user can access.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/drive.file", + "classification": "non_sensitive", + "provider_classification": "Non-sensitive (recommended)", + "source": "drive-scopes", + "role": "required", + "notes": "Wave 1 requests drive.file only; the restricted scope alternative is recorded in decisions/google-restricted-scopes.md." + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "Content of files the app created or the user opened with it.", + "tenant": "Single user.", + "source": "drive-files-get" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client files().get(alt='media') and files().export(). The Workspace Drive MCP server (Developer Preview) requests drive.readonly and drive.file, so MCP would not avoid the restricted tier either.", + "substrate_source": "drive-files-get", + "rate_limit": { + "summary": "files.get costs 5 quota units and a content download 200; per user per project 325,000 units per minute, per project 1,000,000 per minute.", + "source": "drive-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification" + ] + }, + { + "action_id": "google.calendar.list", + "display_name": "Calendar: List Events", + "component_class": "GoogleCalendarListComponent", + "decision": "include", + "rationale": "Read action on a sensitive Calendar scope; independent of the CASA decision. Included in wave 1 by the release owner on 2026-09-01.", + "confidence": "high", + "schema": { + "inputs": [ + { + "name": "calendar_id", + "type": "str", + "required": false, + "description": "Default 'primary'" + }, + { + "name": "time_min", + "type": "datetime", + "required": false + }, + { + "name": "time_max", + "type": "datetime", + "required": false + }, + { + "name": "query", + "type": "str", + "required": false, + "description": "Free-text q" + }, + { + "name": "max_results", + "type": "int", + "required": false, + "description": "Default 250, maximum 2500" + }, + { + "name": "single_events", + "type": "bool", + "required": false, + "description": "Expand recurring events" + }, + { + "name": "order_by", + "type": "str", + "required": false, + "description": "startTime or updated" + }, + { + "name": "page_token", + "type": "str", + "required": false + } + ], + "outputs": [ + { + "name": "events", + "type": "list[Data]", + "required": true + }, + { + "name": "next_page_token", + "type": "str", + "required": false + }, + { + "name": "next_sync_token", + "type": "str", + "required": false + }, + { + "name": "time_zone", + "type": "str", + "required": false + } + ], + "source": "calendar-events-list" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads calendars the connected user can access.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/calendar.events.readonly", + "classification": "sensitive", + "provider_classification": "Sensitive", + "source": "google-sensitive-scope-verification", + "role": "required", + "notes": "Scope text 'View events on all your calendars' from the Calendar scopes page; Google's sensitive-scope guide names reading Calendar events as a sensitive scope and Calendar is not on any restricted list." + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "Events on every calendar the user can access, or one calendar by id.", + "tenant": "Single user.", + "source": "calendar-events-list" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client events().list(). The Workspace Calendar MCP server (Developer Preview) exists and shares the Calendar quota, but is pre-GA.", + "substrate_source": "calendar-events-list", + "rate_limit": { + "summary": "Per user per project 600 requests per minute; per project 10,000 per minute; daily billing threshold 1,000,000 requests; 403 or 429 usageLimits on exceed.", + "source": "calendar-quota", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification", + "google-sensitive-scope-verification" + ] + }, + { + "action_id": "google.calendar.create", + "display_name": "Calendar: Create Event", + "component_class": "GoogleCalendarCreateComponent", + "decision": "include", + "rationale": "Write action on a sensitive Calendar scope; independent of the CASA decision. Included in wave 1 by the release owner on 2026-09-01.", + "confidence": "high", + "schema": { + "inputs": [ + { + "name": "calendar_id", + "type": "str", + "required": false, + "description": "Default 'primary'" + }, + { + "name": "summary", + "type": "str", + "required": true + }, + { + "name": "start", + "type": "datetime", + "required": true + }, + { + "name": "end", + "type": "datetime", + "required": true + }, + { + "name": "description", + "type": "str", + "required": false + }, + { + "name": "location", + "type": "str", + "required": false + }, + { + "name": "attendees", + "type": "list[str]", + "required": false + }, + { + "name": "send_updates", + "type": "str", + "required": false, + "description": "all, externalOnly, none" + }, + { + "name": "recurrence", + "type": "list[str]", + "required": false, + "description": "RRULE lines" + }, + { + "name": "conference_data_version", + "type": "int", + "required": false, + "description": "0 or 1" + } + ], + "outputs": [ + { + "name": "event", + "type": "Data", + "required": true, + "description": "Created Events resource" + } + ], + "source": "calendar-events-insert" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Creates the event as the connected user; attendees are invited from that user.", + "scopes": [ + { + "scope": "https://www.googleapis.com/auth/calendar.events", + "classification": "sensitive", + "provider_classification": "Sensitive", + "source": "google-sensitive-scope-verification", + "role": "required", + "notes": "Scope text 'View and edit events on all your calendars' from the Calendar scopes page; classified sensitive per the sensitive-scope guide." + } + ], + "consent": "user", + "consent_notes": "User consent on the OAuth consent screen. Google Workspace admins govern third-party access through API controls: Trusted (all services), Limited (unrestricted services only), Specific Google data (listed scopes only), or Blocked; by default users can sign in with Google to any third-party app. Admins can also trust internal apps built by their own organization for restricted Workspace APIs.", + "consent_source": "workspace-admin-app-access", + "reach": { + "resource": "Creates events on calendars the user can write to; can invite any attendee.", + "tenant": "Single user.", + "source": "calendar-events-insert" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request access_type=offline to receive a refresh token on the first code exchange; a refresh token is only re-issued after the user revokes the grant or the app sends prompt=consent. Refresh tokens stop working when the user revokes access, after six months of non-use, when the user changes their password and the token carries Gmail scopes, when the account exceeds 100 live refresh tokens for the client, when a Workspace admin restricts the service, or when a time-based grant expires. A project in Testing publishing status issues refresh tokens that expire after 7 days.", + "source": "google-oauth2-overview" + }, + "revocation": { + "behavior": "The app revokes programmatically by POSTing the access or refresh token to https://oauth2.googleapis.com/revoke; later refresh attempts fail with invalid_grant. The user can also revoke the grant from their Google Account, which the platform reports as the refresh token no longer working.", + "source": "google-oauth2-web-server" + }, + "substrate": "sdk", + "substrate_ga_status": "ga", + "substrate_notes": "google-api-python-client events().insert().", + "substrate_source": "calendar-events-insert", + "rate_limit": { + "summary": "Per user per project 600 requests per minute; per project 10,000 per minute; daily billing threshold 1,000,000 requests.", + "source": "calendar-quota", + "confidence": "high" + }, + "verification_dependencies": [ + "google-brand-verification", + "google-sensitive-scope-verification" + ] + } + ] +} diff --git a/design/dedicated-integrations/matrices/microsoft.json b/design/dedicated-integrations/matrices/microsoft.json new file mode 100644 index 000000000000..142900d0082e --- /dev/null +++ b/design/dedicated-integrations/matrices/microsoft.json @@ -0,0 +1,1139 @@ +{ + "schema_version": 1, + "provider": "microsoft", + "display_name": "Microsoft 365 and Teams", + "bundle": { + "extension_id": "lfx-microsoft", + "bundle_name": "microsoft", + "distribution": "lfx-microsoft" + }, + "wave": 1, + "max_included_actions": 8, + "verified_on": "2026-09-01", + "oauth_app_owner_by_context": { + "hosted": "langflow", + "self_managed": "customer", + "desktop": "langflow", + "headless": "customer" + }, + "oauth_client_type_by_context": { + "hosted": "confidential", + "self_managed": "confidential", + "desktop": "public", + "headless": "external" + }, + "substrate_decision": { + "chosen": [ + "rest" + ], + "decision_record": "decisions/substrate-microsoft.md" + }, + "restricted_scope_decisions": [], + "sources": { + "graph-permissions-reference": { + "url": "https://learn.microsoft.com/en-us/graph/permissions-reference", + "title": "Microsoft Graph permissions reference", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Delegated AdminConsentRequired = No for Mail.Send, Mail.Read, Mail.ReadBasic, Calendars.Read, Calendars.ReadWrite, ChatMessage.Send, ChannelMessage.Send, Files.Read, Files.Read.All, Sites.Read.All, offline_access, User.Read. Application-permission variants of the mail, calendar, files, and sites permissions require admin consent; ChatMessage.Send and ChannelMessage.Send have no application variant." + }, + "graph-throttling-limits": { + "url": "https://learn.microsoft.com/en-us/graph/throttling-limits", + "title": "Microsoft Graph throttling limits", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Global: 130,000 requests per 10 seconds per app across all tenants. Outlook: 10,000 requests per 10 minutes and 4 concurrent requests per app per mailbox, 150 MB upload per 5 minutes. Teams: POST /chats/{id}/messages 200 rps per app, 20 rps per app per tenant, 1 rps per chat, 1 rps per user; POST /teams/{id}/channels/{id}/messages 500 rps per app, 50 rps per app per tenant, 1 rps per channel, 1 rps per user; a sustained rate of about 83 percent of the listed value is also enforced. Files and lists defer to the SharePoint throttling page." + }, + "sharepoint-throttling": { + "url": "https://learn.microsoft.com/en-us/sharepoint/dev/general-development/how-to-avoid-getting-throttled-or-blocked-in-sharepoint-online", + "title": "Avoid getting throttled or blocked in SharePoint Online", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "429 or 503 with Retry-After; resource-unit costs (1 single-item or download, 2 list children or writes, 5 permission operations); per-user 3,000 requests per 5 minutes; per-app-per-tenant RU limits by license band; no IETF RateLimit headers; User-Agent decoration NONISV|CompanyName|AppName/Version." + }, + "exchange-online-limits": { + "url": "https://learn.microsoft.com/en-us/office365/servicedescriptions/exchange-online-service-description/exchange-online-limits", + "title": "Exchange Online limits", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Default maximum message size is 35 MB for sending and 36 MB for receiving; admins may configure 1 MB to 150 MB; messages routed outside Microsoft datacenters are capped at 112 MB after encoding." + }, + "work-iq-mcp": { + "url": "https://learn.microsoft.com/en-us/microsoft-agent-365/tooling-servers-overview", + "title": "Work IQ MCP overview (preview)", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "'This is a preview feature. Preview features aren't meant for production use.' 'You must have a Microsoft 365 Copilot license to use Work IQ MCP servers.' Tenant-scoped endpoint https://agent365.svc.cloud.microsoft/agents/tenants/{tenantId}/servers/mcp_MailTools; a customer enterprise app with WorkIQ-* permissions; admins allow or block servers in the Microsoft 365 admin center; servers for Mail, Calendar, Teams, SharePoint, OneDrive, User, Word." + }, + "entra-auth-code-flow": { + "url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow", + "title": "Microsoft identity platform: OAuth 2.0 authorization code flow", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "PKCE (code_challenge S256) recommended for all clients and required for single-page apps; public clients must not use secrets when redeeming codes; http://localhost is the recommended redirect for system-browser native apps; offline_access yields a refresh token; each refresh returns a new refresh token and the old one must be discarded; authority /common, /organizations, or /{tenant}; admin-restricted permissions return an error for non-admin users." + }, + "entra-refresh-tokens": { + "url": "https://learn.microsoft.com/en-us/entra/identity-platform/refresh-tokens", + "title": "Refresh tokens in the Microsoft identity platform", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Default lifetime 90 days (24 hours for SPAs and email OTP flows); refresh tokens replace themselves on every use; revocation table by credential change, user action, and admin action; B2B users must be revoked in the home tenant." + }, + "entra-reply-url": { + "url": "https://learn.microsoft.com/en-us/entra/identity-platform/reply-url", + "title": "Redirect URI (reply URL) best practices and limitations", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "https required except localhost; the port is ignored when matching localhost redirects; IPv6 loopback [::1] unsupported; Microsoft recommends 127.0.0.1 over localhost but an http loopback URI must be added through the application manifest; at most 256 redirect URIs of 256 characters each." + }, + "entra-user-consent": { + "url": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent", + "title": "Configure how users consent to applications", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "'By default, all users are allowed to consent to applications for permissions that don't require administrator consent.' Recommended policy: allow user consent only for verified publishers and low-impact permissions; admin consent workflow lets users request approval." + }, + "entra-publisher-verification": { + "url": "https://learn.microsoft.com/en-us/entra/identity-platform/publisher-verification-overview", + "title": "Publisher verification overview", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Requires a verified Microsoft AI Cloud Partner Program account (Partner One ID) associated with the app's tenant and a matching publisher domain; free; 'Beginning November 2020, if risk-based step-up consent is enabled, users can't consent to most newly registered multitenant apps that aren't publisher verified' when the app requests permissions beyond basic sign-in from users in other tenants." + }, + "graph-user-sendmail": { + "url": "https://learn.microsoft.com/en-us/graph/api/user-sendmail", + "title": "Microsoft Graph: user: sendMail", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST /me/sendMail; least privileged Mail.Send for delegated (work or school and personal) and application; JSON body {message{subject, body{contentType, content}, toRecipients, ccRecipients, bccRecipients, attachments}, saveToSentItems} or base64 MIME; 202 Accepted with empty body; delivery subject to Exchange Online limits." + }, + "graph-user-list-messages": { + "url": "https://learn.microsoft.com/en-us/graph/api/user-list-messages", + "title": "Microsoft Graph: List messages", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET /me/messages and /me/mailFolders/{id}/messages; least privileged Mail.ReadBasic (excludes body, previewBody, attachments), higher Mail.Read or Mail.ReadWrite; supports OData query parameters; $top 1 to 1000 with default 10; $orderby properties must also appear in $filter; bodies returned as HTML unless Prefer: outlook.body-content-type=text." + }, + "graph-user-list-calendarview": { + "url": "https://learn.microsoft.com/en-us/graph/api/user-list-calendarview", + "title": "Microsoft Graph: List calendarView", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET /me/calendarView?startDateTime=...&endDateTime=... (also per calendar); least privileged Calendars.ReadBasic, higher Calendars.Read or Calendars.ReadWrite; expands recurring events into instances; Prefer: outlook.timezone header; startDateTime and endDateTime are required ISO 8601 values." + }, + "graph-user-post-events": { + "url": "https://learn.microsoft.com/en-us/graph/api/user-post-events", + "title": "Microsoft Graph: Create event", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST /me/events or /me/calendars/{id}/events; Calendars.ReadWrite for delegated and application; body is an event resource (subject, body, start and end dateTimeTimeZone, location, attendees, isOnlineMeeting, recurrence); Prefer: outlook.timezone; transactionId to make retries idempotent; 201 Created." + }, + "graph-chat-post-messages": { + "url": "https://learn.microsoft.com/en-us/graph/api/chat-post-messages", + "title": "Microsoft Graph: Send message in a chat", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST /chats/{chat-id}/messages; delegated least privileged ChatMessage.Send, higher Chat.ReadWrite; personal Microsoft accounts not supported; application permission only Teamwork.Migrate.All for migration; 201 Created; 'It's a violation of the terms of use to use Microsoft Teams as a log file.'" + }, + "graph-channel-post-messages": { + "url": "https://learn.microsoft.com/en-us/graph/api/channel-post-messages", + "title": "Microsoft Graph: Send chatMessage in a channel", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "POST /teams/{team-id}/channels/{channel-id}/messages; delegated least privileged ChannelMessage.Send; personal accounts not supported; application permissions only for migration; only body is mandatory; 201 Created." + }, + "graph-driveitem-list-children": { + "url": "https://learn.microsoft.com/en-us/graph/api/driveitem-list-children", + "title": "Microsoft Graph: List children of a driveItem", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET /me/drive/root/children, /drives/{drive-id}/items/{item-id}/children, /sites/{site-id}/drive/items/{item-id}/children; least privileged Files.Read, higher Files.ReadWrite, Files.Read.All, Files.ReadWrite.All, Sites.Read.All, Sites.ReadWrite.All; $expand, $select, $skipToken, $top, $orderby; default page 200 with @odata.nextLink." + }, + "graph-driveitem-get-content": { + "url": "https://learn.microsoft.com/en-us/graph/api/driveitem-get-content", + "title": "Microsoft Graph: Download driveItem content", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "GET /me/drive/items/{item-id}/content and drives/sites/groups forms; least privileged Files.Read; returns 302 to a preauthenticated @microsoft.graph.downloadUrl valid for minutes and requiring no Authorization header; Range header for partial downloads." + }, + "entra-public-client-apps": { + "url": "https://learn.microsoft.com/en-us/entra/identity-platform/msal-client-applications", + "title": "Public and confidential client apps (MSAL)", + "kind": "provider_docs", + "verified_on": "2026-09-02", + "notes": "Public client applications (desktop, mobile, browserless) 'can't have client secrets'; an app registration that serves a public client must enable the public client flow. Together with entra-reply-url (one registration carries redirect URIs for the Web and the Mobile and desktop applications platforms; http://localhost is valid and the port is ignored when matching localhost redirect URIs; 127.0.0.1 is preferred and an http loopback URI is added through the manifest replyUrlsWithType), this is the basis for the Langflow-owned Desktop public client in decisions/desktop-oauth-ownership.md." + } + }, + "verification_programs": { + "entra-publisher-verification": { + "description": "Publisher verification of the Langflow-owned multitenant Entra application via a verified Microsoft AI Cloud Partner Program account and matching publisher domain. Without it, users in customer tenants that keep the default risk-based step-up consent cannot consent to the hosted app. Free; minutes once the partner account is verified.", + "source": "entra-publisher-verification", + "blocking_for_contexts": [ + "hosted" + ] + }, + "m365-copilot-license": { + "description": "A Microsoft 365 Copilot license per tenant is required to use the Work IQ MCP servers, which are also preview and not meant for production. Blocks every context while the servers stay pre-GA and license-gated; only relevant if a Microsoft action chooses the MCP substrate.", + "source": "work-iq-mcp", + "blocking_for_contexts": [ + "hosted", + "self_managed", + "desktop", + "headless" + ] + } + }, + "actions": [ + { + "action_id": "microsoft.outlook.send", + "display_name": "Outlook: Send Mail", + "component_class": "OutlookSendComponent", + "decision": "include", + "confidence": "high", + "rationale": "Highest-demand write action; single delegated permission with no admin consent. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "to", + "type": "list[str]", + "required": true + }, + { + "name": "cc", + "type": "list[str]", + "required": false + }, + { + "name": "bcc", + "type": "list[str]", + "required": false + }, + { + "name": "subject", + "type": "str", + "required": true + }, + { + "name": "body", + "type": "str", + "required": true + }, + { + "name": "body_is_html", + "type": "bool", + "required": false + }, + { + "name": "attachments", + "type": "list[File]", + "required": false, + "description": "fileAttachment entries" + }, + { + "name": "save_to_sent_items", + "type": "bool", + "required": false + } + ], + "outputs": [ + { + "name": "result", + "type": "Data", + "required": true, + "description": "202 Accepted; no body, so the output carries the request echo and status" + } + ], + "source": "graph-user-sendmail" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Sends as the signed-in user (/me/sendMail). Sending as another mailbox needs the Mail.Send application permission with admin consent, excluded from wave 1.", + "scopes": [ + { + "scope": "Mail.Send", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Send mail as a user'; application variant requires admin consent and is excluded." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "Required to receive a refresh token." + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "The signed-in user's mailbox; any recipient; message size default 35 MB, configurable up to 150 MB by the tenant.", + "tenant": "Single user.", + "source": "exchange-online-limits" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA) via msgraph-sdk or httpx. Work IQ Mail MCP is preview, not for production, and needs a Copilot license.", + "substrate_source": "graph-user-sendmail", + "rate_limit": { + "summary": "Outlook service: 10,000 requests per 10-minute period and four concurrent requests per app per mailbox; 150 MB of uploads (PATCH, POST, PUT) per 5 minutes; global Graph limit 130,000 requests per 10 seconds per app across all tenants; 429 with Retry-After.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.outlook.search", + "display_name": "Outlook: Search Mail", + "component_class": "OutlookSearchComponent", + "decision": "include", + "confidence": "medium", + "rationale": "Read action on delegated mail permissions without admin consent. Wave 1 requests Mail.Read because the component can return message bodies; Mail.ReadBasic cannot. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "search", + "type": "str", + "required": false, + "description": "$search expression" + }, + { + "name": "filter", + "type": "str", + "required": false, + "description": "$filter expression" + }, + { + "name": "folder_id", + "type": "str", + "required": false, + "description": "mailFolders/{id} scope" + }, + { + "name": "top", + "type": "int", + "required": false, + "description": "1 to 1000, default 10" + }, + { + "name": "select", + "type": "list[str]", + "required": false + }, + { + "name": "include_body", + "type": "bool", + "required": false, + "description": "Requires Mail.Read" + } + ], + "outputs": [ + { + "name": "messages", + "type": "list[Data]", + "required": true + }, + { + "name": "next_link", + "type": "str", + "required": false + } + ], + "source": "graph-user-list-messages" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads the signed-in user's mailbox and folders shared with that user.", + "scopes": [ + { + "scope": "Mail.Read", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Read user mail': full message including body. Requested in wave 1: the action returns bodies through include_body, which ReadBasic excludes (resolved 2026-09-01)." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Every message in the signed-in user's mailbox including Deleted Items and Clutter; bodies when Mail.Read is granted.", + "tenant": "Single user; shared folders only where another user delegated access.", + "source": "graph-user-list-messages" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA).", + "substrate_source": "graph-user-list-messages", + "rate_limit": { + "summary": "Outlook service: 10,000 requests per 10-minute period and four concurrent requests per app per mailbox; 150 MB of uploads (PATCH, POST, PUT) per 5 minutes; global Graph limit 130,000 requests per 10 seconds per app across all tenants; 429 with Retry-After.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.calendar.list", + "display_name": "Outlook Calendar: List Events", + "component_class": "OutlookCalendarListComponent", + "decision": "include", + "confidence": "medium", + "rationale": "Read action on a delegated calendar permission without admin consent. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "start", + "type": "datetime", + "required": true + }, + { + "name": "end", + "type": "datetime", + "required": true + }, + { + "name": "calendar_id", + "type": "str", + "required": false, + "description": "Default calendar when omitted" + }, + { + "name": "top", + "type": "int", + "required": false + }, + { + "name": "select", + "type": "list[str]", + "required": false + }, + { + "name": "time_zone", + "type": "str", + "required": false, + "description": "Prefer: outlook.timezone" + } + ], + "outputs": [ + { + "name": "events", + "type": "list[Data]", + "required": true, + "description": "Recurring events expanded into instances" + }, + { + "name": "next_link", + "type": "str", + "required": false + } + ], + "source": "graph-user-list-calendarview" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads calendars of the signed-in user.", + "scopes": [ + { + "scope": "Calendars.Read", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Read user calendars'; Calendars.ReadBasic is the narrower least-privileged option for calendarView. Wave 1 requests Calendars.Read because the events output carries body and attendee detail, which Calendars.ReadBasic excludes (resolved 2026-09-01)." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Events in the signed-in user's calendars within the requested window.", + "tenant": "Single user.", + "source": "graph-user-list-calendarview" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA).", + "substrate_source": "graph-user-list-calendarview", + "rate_limit": { + "summary": "Outlook service: 10,000 requests per 10-minute period and four concurrent requests per app per mailbox; 150 MB of uploads (PATCH, POST, PUT) per 5 minutes; global Graph limit 130,000 requests per 10 seconds per app across all tenants; 429 with Retry-After.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.calendar.create", + "display_name": "Outlook Calendar: Create Event", + "component_class": "OutlookCalendarCreateComponent", + "decision": "include", + "confidence": "high", + "rationale": "Write action on a delegated calendar permission without admin consent. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "subject", + "type": "str", + "required": true + }, + { + "name": "start", + "type": "datetime", + "required": true + }, + { + "name": "end", + "type": "datetime", + "required": true + }, + { + "name": "time_zone", + "type": "str", + "required": false, + "description": "dateTimeTimeZone.timeZone" + }, + { + "name": "body", + "type": "str", + "required": false + }, + { + "name": "location", + "type": "str", + "required": false + }, + { + "name": "attendees", + "type": "list[str]", + "required": false + }, + { + "name": "is_online_meeting", + "type": "bool", + "required": false + }, + { + "name": "recurrence", + "type": "Data", + "required": false + }, + { + "name": "calendar_id", + "type": "str", + "required": false + }, + { + "name": "transaction_id", + "type": "str", + "required": false, + "description": "Idempotent retries" + } + ], + "outputs": [ + { + "name": "event", + "type": "Data", + "required": true, + "description": "201 Created event resource" + } + ], + "source": "graph-user-post-events" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Creates the event as the signed-in user; invitations go out from that user.", + "scopes": [ + { + "scope": "Calendars.ReadWrite", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Have full access to user calendars'." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Creates events in the signed-in user's calendars; may invite any attendee.", + "tenant": "Single user.", + "source": "graph-user-post-events" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA).", + "substrate_source": "graph-user-post-events", + "rate_limit": { + "summary": "Outlook service: 10,000 requests per 10-minute period and four concurrent requests per app per mailbox; 150 MB of uploads (PATCH, POST, PUT) per 5 minutes; global Graph limit 130,000 requests per 10 seconds per app across all tenants; 429 with Retry-After.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.teams.chat_post", + "display_name": "Teams: Post Chat Message", + "component_class": "TeamsChatPostComponent", + "decision": "include", + "confidence": "high", + "rationale": "Posts as the signed-in user into an existing 1:1 or group chat. Only a delegated permission exists (application permission is migration-only), which matches the user-delegated default. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "chat_id", + "type": "str", + "required": true + }, + { + "name": "content", + "type": "str", + "required": true + }, + { + "name": "content_type", + "type": "str", + "required": false, + "description": "text or html" + }, + { + "name": "mentions", + "type": "list[Data]", + "required": false + }, + { + "name": "attachments", + "type": "list[Data]", + "required": false + } + ], + "outputs": [ + { + "name": "message", + "type": "Data", + "required": true, + "description": "201 Created chatMessage" + } + ], + "source": "graph-chat-post-messages" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Message appears from the signed-in user; personal Microsoft accounts are not supported; cannot create a chat, only post into one.", + "scopes": [ + { + "scope": "ChatMessage.Send", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Send user chat messages'; no application variant." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Chats the signed-in user is a member of.", + "tenant": "Single user; the user's tenant and any tenant the chat spans.", + "source": "graph-chat-post-messages" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA). Work IQ Teams MCP is preview and license-gated.", + "substrate_source": "graph-chat-post-messages", + "rate_limit": { + "summary": "POST /chats/{chat-id}/messages: 200 rps per app across tenants, 20 rps per app per tenant, 1 rps per chat, 1 rps per user; sustained traffic above about 83 percent of the limit is also throttled.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.teams.channel_post", + "display_name": "Teams: Post Channel Message", + "component_class": "TeamsChannelPostComponent", + "decision": "include", + "confidence": "high", + "rationale": "Posts as the signed-in user into a team channel; delegated-only permission. Candidate to merge with chat_post into one component with two outputs if the include set must shrink. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "team_id", + "type": "str", + "required": true + }, + { + "name": "channel_id", + "type": "str", + "required": true + }, + { + "name": "content", + "type": "str", + "required": true + }, + { + "name": "content_type", + "type": "str", + "required": false, + "description": "text or html" + }, + { + "name": "mentions", + "type": "list[Data]", + "required": false + }, + { + "name": "attachments", + "type": "list[Data]", + "required": false + } + ], + "outputs": [ + { + "name": "message", + "type": "Data", + "required": true, + "description": "201 Created chatMessage with webUrl" + } + ], + "source": "graph-channel-post-messages" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Message appears from the signed-in user; personal Microsoft accounts are not supported.", + "scopes": [ + { + "scope": "ChannelMessage.Send", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Send channel messages'; no application variant outside migration." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Channels of teams the signed-in user belongs to.", + "tenant": "Single user.", + "source": "graph-channel-post-messages" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA).", + "substrate_source": "graph-channel-post-messages", + "rate_limit": { + "summary": "POST /teams/{team-id}/channels/{channel-id}/messages: 500 rps per app across tenants, 50 rps per app per tenant, 1 rps per channel, 1 rps per user.", + "source": "graph-throttling-limits", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.files.list", + "display_name": "SharePoint/OneDrive: List Items", + "component_class": "SharePointListComponent", + "decision": "include", + "confidence": "medium", + "rationale": "Lists drive items the signed-in user can reach; all candidate permissions are delegated without admin consent, so the choice is about reach, not consent. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "drive_id", + "type": "str", + "required": false, + "description": "Default: the user's OneDrive" + }, + { + "name": "site_id", + "type": "str", + "required": false, + "description": "SharePoint site drive" + }, + { + "name": "item_id", + "type": "str", + "required": false, + "description": "Default: root" + }, + { + "name": "path", + "type": "str", + "required": false, + "description": "Path relative to root" + }, + { + "name": "top", + "type": "int", + "required": false + }, + { + "name": "select", + "type": "list[str]", + "required": false + }, + { + "name": "order_by", + "type": "str", + "required": false + } + ], + "outputs": [ + { + "name": "items", + "type": "list[Data]", + "required": true, + "description": "driveItem resources" + }, + { + "name": "next_link", + "type": "str", + "required": false + } + ], + "source": "graph-driveitem-list-children" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads what the signed-in user can access.", + "scopes": [ + { + "scope": "Files.Read", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Read user files': the user's own OneDrive; least privileged for list children. Wave 1 default (resolved 2026-09-01): the wider scopes below are optional and granted when the user enables SharePoint or shared-library access on the connection; a run that sets site_id or a foreign drive_id against a connection without them fails with scope-missing (connection-contract.md section 7)." + }, + { + "scope": "Files.Read.All", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "optional", + "condition": { + "kind": "input_present", + "input": "drive_id" + }, + "notes": "'Read all files that user can access', including SharePoint libraries." + }, + { + "scope": "Sites.Read.All", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "optional", + "condition": { + "kind": "input_present", + "input": "site_id" + }, + "notes": "'Read items in all site collections' on behalf of the user; needed for /sites/{site-id}/drive." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Folder listings in the user's OneDrive (Files.Read) or any drive and site the user can access (Files.Read.All, Sites.Read.All).", + "tenant": "Single user's effective permissions.", + "source": "graph-driveitem-list-children" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA). Work IQ SharePoint and OneDrive MCP are preview and license-gated.", + "substrate_source": "graph-driveitem-list-children", + "rate_limit": { + "summary": "SharePoint and OneDrive throttle in resource units: 1 RU for a single-item get or download, 2 RU for list children, create, update; per user 3,000 requests per 5 minutes; per app per tenant 1,250 to 6,250 RU per minute and 1.2M to 6M RU per 24 hours depending on tenant license count; 429 or 503 with Retry-After; decorate traffic with a NONISV|Company|App/Version User-Agent.", + "source": "sharepoint-throttling", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + }, + { + "action_id": "microsoft.files.fetch", + "display_name": "SharePoint/OneDrive: Fetch Item", + "component_class": "SharePointFetchComponent", + "decision": "include", + "confidence": "high", + "rationale": "Downloads a drive item the signed-in user can access. Same permission choice as files.list; the two rows should land on the same decision. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "item_id", + "type": "str", + "required": false, + "description": "driveItem id" + }, + { + "name": "path", + "type": "str", + "required": false, + "description": "Path relative to root" + }, + { + "name": "drive_id", + "type": "str", + "required": false + }, + { + "name": "site_id", + "type": "str", + "required": false + }, + { + "name": "range", + "type": "str", + "required": false, + "description": "Optional byte range" + } + ], + "outputs": [ + { + "name": "file", + "type": "Data", + "required": true, + "description": "driveItem metadata plus downloaded bytes" + } + ], + "source": "graph-driveitem-get-content" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads what the signed-in user can access; the 302 download URL is preauthenticated and expires within minutes, so the component must consume it immediately and never store it.", + "scopes": [ + { + "scope": "Files.Read", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required", + "notes": "'Read user files': the user's own OneDrive; wave 1 default, same profile as microsoft.files.list (resolved 2026-09-01)." + }, + { + "scope": "Files.Read.All", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "optional", + "condition": { + "kind": "input_present", + "input": "drive_id" + } + }, + { + "scope": "Sites.Read.All", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "optional", + "condition": { + "kind": "input_present", + "input": "site_id" + }, + "notes": "Needed for the /sites/{site-id}/drive download path behind the site_id input; same permission set as microsoft.files.list." + }, + { + "scope": "offline_access", + "classification": "non_sensitive", + "provider_classification": "Delegated; admin consent required: No (work or school and personal accounts)", + "source": "graph-permissions-reference", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "User consent on the Entra consent prompt. By default all users may consent to permissions that do not require admin consent; tenants may restrict user consent to verified publishers and low-impact permissions, or disable it and route requests through the admin consent workflow. A multitenant app registered after November 2020 that is not publisher verified cannot be consented to by users in other tenants when risk-based step-up consent is enabled.", + "consent_source": "entra-user-consent", + "reach": { + "resource": "Content of files the user can access.", + "tenant": "Single user's effective permissions.", + "source": "graph-driveitem-get-content" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Request offline_access to receive a refresh token; the default refresh-token lifetime is 90 days (24 hours for single-page apps), and every refresh returns a new refresh token that must replace the old one. Refresh tokens are revoked when the user or an admin revokes all refresh tokens, when an admin resets the password from the Entra or M365 admin center, and for password-based public-client tokens when the user changes their password; confidential-client tokens survive a user password change. Access tokens are short-lived (expires_in is 3599 seconds in the reference example).", + "source": "entra-refresh-tokens" + }, + "revocation": { + "behavior": "There is no RFC 7009 revoke endpoint; revocation happens server-side when the user revokes their refresh tokens, an admin revokes all refresh tokens for the user, or a credential change matches the revocation table. Langflow deletes its stored refresh token on disconnect and the next refresh attempt fails with an interaction_required or invalid_grant error.", + "source": "entra-refresh-tokens" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Microsoft Graph v1.0 REST (GA).", + "substrate_source": "graph-driveitem-get-content", + "rate_limit": { + "summary": "SharePoint and OneDrive throttle in resource units: 1 RU for a single-item get or download, 2 RU for list children, create, update; per user 3,000 requests per 5 minutes; per app per tenant 1,250 to 6,250 RU per minute and 1.2M to 6M RU per 24 hours depending on tenant license count; 429 or 503 with Retry-After; decorate traffic with a NONISV|Company|App/Version User-Agent.", + "source": "sharepoint-throttling", + "confidence": "high" + }, + "verification_dependencies": [ + "entra-publisher-verification" + ] + } + ] +} diff --git a/design/dedicated-integrations/matrices/slack.json b/design/dedicated-integrations/matrices/slack.json new file mode 100644 index 000000000000..8ae24a0c2811 --- /dev/null +++ b/design/dedicated-integrations/matrices/slack.json @@ -0,0 +1,872 @@ +{ + "schema_version": 1, + "provider": "slack", + "display_name": "Slack", + "bundle": { + "extension_id": "lfx-slack", + "bundle_name": "slack", + "distribution": "lfx-slack" + }, + "wave": 1, + "max_included_actions": 8, + "verified_on": "2026-09-01", + "oauth_app_owner_by_context": { + "hosted": "langflow", + "self_managed": "customer", + "desktop": "langflow", + "headless": "customer" + }, + "oauth_client_type_by_context": { + "hosted": "confidential", + "self_managed": "confidential", + "desktop": "public", + "headless": "external" + }, + "substrate_decision": { + "chosen": [ + "rest" + ], + "decision_record": "decisions/substrate-slack.md" + }, + "restricted_scope_decisions": [], + "sources": { + "slack-scopes": { + "url": "https://docs.slack.dev/reference/scopes", + "title": "Slack: Permission scopes reference", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Descriptions: search:read 'Search a workspace's content'; channels:history / groups:history / im:history / mpim:history 'View messages and other content in ... that your Slack app has been added to'; chat:write 'Send messages as your Slack app'; canvases:read 'Access contents of canvases created inside Slack'; canvases:write 'Create, edit and remove canvases'; reactions:write 'Add and edit emoji reactions'; channels:read / groups:read 'View basic information about ... channels'; users:read 'View people in a workspace'. Token-type availability is taken from each method page." + }, + "slack-mcp-server": { + "url": "https://docs.slack.dev/ai/slack-mcp-server/", + "title": "Slack: Slack MCP server", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Endpoint https://mcp.slack.com/mcp, JSON-RPC 2.0 over Streamable HTTP; 'Slack supports confidential OAuth for MCP clients'; user tokens; 'Only directory-published apps or internal apps may use MCP'; workspace admins approve and manage MCP client integrations; tools cover search, retrieving and sending messages, canvases, users, files, lists; per-tool rate tiers from Tier 2 to Tier 4 with special limits for search and posting. The page carries no availability label (no preview or beta marker). The page names capabilities (search messages and files, read channel history and send messages, create/update/read canvases, fetch user info, list channel members, file upload via the slack_get_file_upload_url and slack_complete_file_upload tools, lists) and requests the granular search scopes search:read.public, search:read.private, search:read.mpim, search:read.im; it does not enumerate the tool identifiers for search, messages, or canvases. Authentication: 'Slack supports confidential OAuth for MCP clients' with the app's client_id and client_secret; a 'Consider using PKCE' callout adds 'Looking to use desktop clients? PKCE support is now available!'; MCP clients must be backed by a registered Slack app with a fixed app ID; SSE and Dynamic Client Registration are not supported; user-token endpoints are https://slack.com/oauth/v2_user/authorize and oauth.v2.user.access." + }, + "slack-pkce": { + "url": "https://docs.slack.dev/authentication/using-pkce/", + "title": "Slack: Using PKCE", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "code_challenge_method must be S256; custom URI schemes are always treated as desktop redirects and localhost redirects are desktop when the app opts into PKCE; 'Desktop redirects are not allowed to request bot scopes'; the token exchange omits client_secret and sends code_verifier." + }, + "slack-rate-limits": { + "url": "https://docs.slack.dev/apis/web-api/rate-limits", + "title": "Slack: Web API rate limits", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Tier 1: 1+ per minute; Tier 2: 20+ per minute; Tier 3: 50+ per minute; Tier 4: 100+ per minute; Special tier per method; limits apply per API method per workspace per app; 429 with Retry-After in seconds; since May 29, 2025 newly created commercially distributed non-Marketplace apps have reduced limits on conversations.history and conversations.replies." + }, + "slack-oauth-install": { + "url": "https://docs.slack.dev/authentication/installing-with-oauth", + "title": "Slack: Installing with OAuth", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "https://slack.com/oauth/v2/authorize with scope (bot) and user_scope (user); oauth.v2.access exchange requires client_id and client_secret (or PKCE); response carries the bot access_token and optionally authed_user.access_token; 'The redirect_uri must use HTTPS'; 'OAuth tokens do not expire' unless rotation is enabled." + }, + "slack-token-rotation": { + "url": "https://docs.slack.dev/authentication/using-token-rotation/", + "title": "Slack: Using token rotation", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Rotation 'may not be turned off once it's turned on'; access tokens expire every 12 hours (43,200 seconds); refresh with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a grace period; applies to bot and user tokens." + }, + "slack-auth-revoke": { + "url": "https://docs.slack.dev/reference/methods/auth.revoke", + "title": "Slack: auth.revoke", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Revokes the calling token (bot or user); test=1 dry run; Tier 3." + }, + "slack-remove-apps": { + "url": "https://slack.com/help/articles/360003125231-Remove-apps-and-custom-integrations-from-your-workspace", + "title": "Slack Help: Remove apps and custom integrations from your workspace", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "Owners remove an app via Tools & settings > Manage apps > Installed Apps > Configuration > Remove App; members revoke their own authorization under Authorized members > Revoke; with app approval enabled only owners and app managers can remove apps." + }, + "slack-app-approval": { + "url": "https://slack.com/help/articles/222386767-Manage-app-approval-for-your-workspace", + "title": "Slack Help: Manage app approval for your workspace", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "By default members can install apps without Workspace Owner approval; owners can enable app approval and pre-approve or restrict apps; members then request apps; an Enterprise org app management policy enables approval automatically." + }, + "slack-search-messages": { + "url": "https://docs.slack.dev/reference/methods/search.messages", + "title": "Slack: search.messages", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "User token with search:read; query required; count max 100 default 20; page or cursor; sort score or timestamp; response messages.matches with pagination and total_count; Tier 2." + }, + "slack-conversations-replies": { + "url": "https://docs.slack.dev/reference/methods/conversations.replies", + "title": "Slack: conversations.replies", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "channels:history, groups:history, im:history, mpim:history; bot or user token; channel and ts required; cursor, limit, oldest, latest, inclusive; Tier 3 for Marketplace apps; commercially distributed non-Marketplace apps created after May 29, 2025 get 1 request per minute with limit capped at 15." + }, + "slack-chat-postmessage": { + "url": "https://docs.slack.dev/reference/methods/chat.postMessage", + "title": "Slack: chat.postMessage", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "chat:write with bot or user token; channel required; text, blocks, attachments, thread_ts, reply_broadcast, unfurl_links; username and icon need chat:write.customize; response ok, channel, ts, message; special rate limit of about 1 message per second per channel with burst allowance; 40,000-character truncation." + }, + "slack-reactions-add": { + "url": "https://docs.slack.dev/reference/methods/reactions.add", + "title": "Slack: reactions.add", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "reactions:write with bot or user token; channel, name, timestamp; errors already_reacted and too_many_reactions; Tier 3." + }, + "slack-conversations-members": { + "url": "https://docs.slack.dev/reference/methods/conversations.members", + "title": "Slack: conversations.members", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "channels:read, groups:read, im:read, mpim:read with bot or user token; channel required; cursor and limit; returns members[] of user ids and response_metadata.next_cursor; Tier 4." + }, + "slack-canvases-create": { + "url": "https://docs.slack.dev/reference/methods/canvases.create", + "title": "Slack: canvases.create", + "kind": "provider_docs", + "verified_on": "2026-09-01", + "notes": "canvases:write with bot or user token; title, document_content of type markdown up to 1 MiB, channel_id (required for free teams); returns canvas_id; Tier 2; free teams cannot create standalone canvases." + }, + "slack-mcp-ga-announcement": { + "url": "https://slack.com/blog/news/mcp-real-time-search-api-now-available", + "title": "Slack blog (2026-02-17): Slack securely powers your third-party agents with your business context", + "kind": "provider_changelog", + "verified_on": "2026-09-01", + "notes": "Announces 'the general availability of Slack's Real-Time Search (RTS) API and Model Context Protocol (MCP) server' on February 17, 2026; names Claude, ChatGPT, Perplexity, Google, Cursor, and Marketplace agents as connecting clients." + }, + "slack-mcp-changelog": { + "url": "https://docs.slack.dev/changelog/2026/02/17/slack-mcp/", + "title": "Slack Developer changelog (2026-02-17): Announcing the Slack MCP server and Real-time Search API", + "kind": "provider_changelog", + "verified_on": "2026-09-01", + "notes": "Companion changelog entry; states the server exposes tools 'designed for LLM-driven discovery, configuration, and execution' and points to the server documentation." + } + }, + "verification_programs": { + "slack-directory-publication": { + "description": "The Slack MCP server admits only directory-published (Marketplace) apps or internal apps. A Langflow-owned hosted Slack app that uses the MCP substrate must be published to the Slack Marketplace; self-managed customers can instead register an internal app in their own workspace.", + "source": "slack-mcp-server", + "blocking_for_contexts": [ + "hosted" + ] + }, + "slack-marketplace-rate-tier": { + "description": "Since May 29, 2025 commercially distributed apps that are not approved for the Slack Marketplace get reduced limits on conversations.history and conversations.replies (1 request per minute, 15 messages). Marketplace approval restores the standard tiers for the Web API path of read actions.", + "source": "slack-rate-limits", + "blocking_for_contexts": [ + "hosted" + ] + } + }, + "actions": [ + { + "action_id": "slack.user.search", + "display_name": "Slack: Search (as user)", + "component_class": "SlackSearchComponent", + "decision": "include", + "confidence": "high", + "rationale": "Web API search.messages is GA, user-token only, and has a published request and response contract. The undocumented MCP tool identifier cannot satisfy the discovery gate, so MCP adoption is deferred. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "query", + "type": "str", + "required": true + }, + { + "name": "count", + "type": "int", + "required": false, + "description": "Max 100, default 20" + }, + { + "name": "sort", + "type": "str", + "required": false, + "description": "score or timestamp" + }, + { + "name": "sort_dir", + "type": "str", + "required": false + }, + { + "name": "cursor", + "type": "str", + "required": false + } + ], + "outputs": [ + { + "name": "matches", + "type": "list[Data]", + "required": true + }, + { + "name": "pagination", + "type": "Data", + "required": false + } + ], + "source": "slack-search-messages" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Searches with the connected user's visibility; user token only.", + "scopes": [ + { + "scope": "search:read", + "classification": "sensitive", + "provider_classification": "User token scope; content reading", + "source": "slack-search-messages", + "role": "required", + "notes": "Required by Web API search.messages; user token only." + } + ], + "consent": "user", + "consent_notes": "The installing user authorizes the requested user_scope on the Slack OAuth screen. By default members can install apps without Workspace Owner approval; owners can enable app approval, and an Enterprise Grid org policy enables it automatically. Workspace admins can also approve and manage MCP client integrations.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Messages and files visible to the connected user across the workspace.", + "tenant": "One workspace per installation.", + "source": "slack-search-messages" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API search.messages (GA) via slack_sdk. MCP adoption is deferred until a dated tools/list capture freezes the exact tool identifier and schema.", + "substrate_source": "slack-search-messages", + "rate_limit": { + "summary": "Web API search.messages is Tier 2 (20+ per minute per workspace per app).", + "source": "slack-search-messages", + "confidence": "high" + }, + "verification_dependencies": [] + }, + { + "action_id": "slack.user.read_thread", + "display_name": "Slack: Read Thread (as user)", + "component_class": "SlackReadThreadComponent", + "decision": "include", + "confidence": "high", + "open_questions": [ + "A Langflow-owned hosted app must be Marketplace-approved or conversations.replies gets 1 request per minute with a 15-message limit." + ], + "rationale": "Web API conversations.replies is GA, scoped to the connected user's visibility, and has a published request and response contract. The hosted Marketplace review remains a calendar dependency. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "channel", + "type": "str", + "required": true + }, + { + "name": "ts", + "type": "str", + "required": true, + "description": "Parent message timestamp" + }, + { + "name": "limit", + "type": "int", + "required": false + }, + { + "name": "cursor", + "type": "str", + "required": false + }, + { + "name": "oldest", + "type": "str", + "required": false + }, + { + "name": "latest", + "type": "str", + "required": false + } + ], + "outputs": [ + { + "name": "messages", + "type": "list[Data]", + "required": true + }, + { + "name": "has_more", + "type": "bool", + "required": false + }, + { + "name": "next_cursor", + "type": "str", + "required": false + } + ], + "source": "slack-conversations-replies" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Reads only conversations the connected user can see.", + "scopes": [ + { + "scope": "channels:history", + "classification": "sensitive", + "provider_classification": "User token scope; content reading", + "source": "slack-scopes", + "role": "required", + "notes": "Wave 1 requests all four history scopes so one stable connection profile can read threads across every supported conversation type." + }, + { + "scope": "groups:history", + "classification": "sensitive", + "provider_classification": "User token scope; content reading", + "source": "slack-scopes", + "role": "required" + }, + { + "scope": "im:history", + "classification": "sensitive", + "provider_classification": "User token scope; content reading", + "source": "slack-scopes", + "role": "required" + }, + { + "scope": "mpim:history", + "classification": "sensitive", + "provider_classification": "User token scope; content reading", + "source": "slack-scopes", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "The installing user authorizes the requested user_scope on the Slack OAuth screen. By default members can install apps without Workspace Owner approval; owners can enable app approval, and an Enterprise Grid org policy enables it automatically. Workspace admins can also approve and manage MCP client integrations.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Thread replies in public channels, private channels, DMs, and group DMs the user belongs to.", + "tenant": "One workspace per installation.", + "source": "slack-conversations-replies" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API conversations.replies (GA) via slack_sdk. MCP adoption is deferred until a dated tools/list capture freezes the exact tool identifier and schema.", + "substrate_source": "slack-conversations-replies", + "rate_limit": { + "summary": "Web API conversations.replies is Tier 3 (50+ per minute) for Marketplace apps and 1 request per minute with limit 15 for commercially distributed non-Marketplace apps created after 2025-05-29.", + "source": "slack-conversations-replies", + "confidence": "high" + }, + "verification_dependencies": [ + "slack-marketplace-rate-tier" + ] + }, + { + "action_id": "slack.user.send", + "display_name": "Slack: Send Message (as user)", + "component_class": "SlackSendAsUserComponent", + "decision": "include", + "confidence": "high", + "rationale": "Web API chat.postMessage is GA and posts as the connected user, distinct from posting as the app. It works on Desktop because user scopes are allowed on desktop redirects. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "channel", + "type": "str", + "required": true + }, + { + "name": "text", + "type": "str", + "required": true + }, + { + "name": "thread_ts", + "type": "str", + "required": false + }, + { + "name": "blocks", + "type": "list[Data]", + "required": false + }, + { + "name": "unfurl_links", + "type": "bool", + "required": false + } + ], + "outputs": [ + { + "name": "message", + "type": "Data", + "required": true, + "description": "channel, ts, message" + } + ], + "source": "slack-chat-postmessage" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Message is attributed to the connected user.", + "scopes": [ + { + "scope": "chat:write", + "classification": "non_sensitive", + "provider_classification": "User token scope", + "source": "slack-scopes", + "role": "required", + "notes": "Also available as a bot scope; here requested as a user scope." + } + ], + "consent": "user", + "consent_notes": "The installing user authorizes the requested user_scope on the Slack OAuth screen. By default members can install apps without Workspace Owner approval; owners can enable app approval, and an Enterprise Grid org policy enables it automatically. Workspace admins can also approve and manage MCP client integrations.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Any channel or conversation the user can post to.", + "tenant": "One workspace per installation.", + "source": "slack-chat-postmessage" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API chat.postMessage (GA) via slack_sdk with the connected user's token. MCP adoption is deferred until a dated tools/list capture freezes the exact tool identifier and schema.", + "substrate_source": "slack-chat-postmessage", + "rate_limit": { + "summary": "chat.postMessage special tier: about 1 message per second per channel with burst allowance; 40,000-character truncation.", + "source": "slack-chat-postmessage", + "confidence": "high" + }, + "verification_dependencies": [] + }, + { + "action_id": "slack.user.canvas", + "display_name": "Slack: Create Canvas (as user)", + "component_class": "SlackCanvasComponent", + "decision": "include", + "confidence": "medium", + "open_questions": [ + "Free teams cannot create standalone canvases; decide whether the UI requires channel_id for that plan type." + ], + "rationale": "Web API canvases.create is GA with a published request and response contract. It is the lowest-demand user action and the first to prune if the include set must shrink. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "title", + "type": "str", + "required": false + }, + { + "name": "markdown", + "type": "str", + "required": true, + "description": "document_content type markdown, up to 1 MiB" + }, + { + "name": "channel_id", + "type": "str", + "required": false, + "description": "Required for free teams" + } + ], + "outputs": [ + { + "name": "canvas_id", + "type": "str", + "required": true + } + ], + "source": "slack-canvases-create" + }, + "auth_mode": "oauth2_authorization_code", + "identity": "user_delegated", + "identity_notes": "Canvas is owned by the connected user.", + "scopes": [ + { + "scope": "canvases:write", + "classification": "non_sensitive", + "provider_classification": "User token scope", + "source": "slack-scopes", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "The installing user authorizes the requested user_scope on the Slack OAuth screen. By default members can install apps without Workspace Owner approval; owners can enable app approval, and an Enterprise Grid org policy enables it automatically. Workspace admins can also approve and manage MCP client integrations.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Canvases the user can create or read; free teams limited to one canvas tab per channel.", + "tenant": "One workspace per installation.", + "source": "slack-canvases-create" + }, + "deployment_contexts": { + "hosted": "server_redirect", + "self_managed": "server_redirect", + "desktop": "loopback_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API canvases.create (GA) via slack_sdk. MCP adoption is deferred until a dated tools/list capture freezes the exact tool identifier and schema.", + "substrate_source": "slack-canvases-create", + "rate_limit": { + "summary": "canvases.create is Tier 2 (20+ per minute per workspace per app).", + "source": "slack-canvases-create", + "confidence": "high" + }, + "verification_dependencies": [] + }, + { + "action_id": "slack.bot.post", + "display_name": "Slack: Post Message (as app)", + "component_class": "SlackPostAsAppComponent", + "decision": "include", + "confidence": "high", + "rationale": "Bot identity is excluded from the MCP server, so this runs on the Web API with a bot token from a workspace installation. Desktop is excluded because desktop redirects may not request bot scopes. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "channel", + "type": "str", + "required": true + }, + { + "name": "text", + "type": "str", + "required": true + }, + { + "name": "thread_ts", + "type": "str", + "required": false + }, + { + "name": "reply_broadcast", + "type": "bool", + "required": false + }, + { + "name": "blocks", + "type": "list[Data]", + "required": false + }, + { + "name": "attachments", + "type": "list[Data]", + "required": false + }, + { + "name": "unfurl_links", + "type": "bool", + "required": false + } + ], + "outputs": [ + { + "name": "message", + "type": "Data", + "required": true, + "description": "channel, ts, message" + } + ], + "source": "slack-chat-postmessage" + }, + "auth_mode": "bot_token_install", + "identity": "bot", + "identity_notes": "Message is attributed to the app's bot user; the bot must be a member of the channel.", + "scopes": [ + { + "scope": "chat:write", + "classification": "non_sensitive", + "provider_classification": "Bot token scope", + "source": "slack-scopes", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "A workspace installation by a member (or an owner where app approval is enabled) grants the bot scopes; the bot token belongs to the workspace, not the installing user.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Channels the bot has been added to.", + "tenant": "One workspace per installation.", + "source": "slack-chat-postmessage" + }, + "deployment_contexts": { + "hosted": "app_install_redirect", + "self_managed": "app_install_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API chat.postMessage (GA) via slack_sdk; the MCP server does not issue bot tokens.", + "substrate_source": "slack-chat-postmessage", + "rate_limit": { + "summary": "chat.postMessage special tier: about 1 message per second per channel with burst allowance; workspace-level caps of several hundred per minute; 40,000-character truncation.", + "source": "slack-chat-postmessage", + "confidence": "high" + }, + "verification_dependencies": [] + }, + { + "action_id": "slack.bot.add_reaction", + "display_name": "Slack: Add Reaction (as app)", + "component_class": "SlackAddReactionComponent", + "decision": "include", + "confidence": "high", + "rationale": "Low-risk bot write; candidate to prune if the bot set must shrink. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "channel", + "type": "str", + "required": true + }, + { + "name": "timestamp", + "type": "str", + "required": true + }, + { + "name": "name", + "type": "str", + "required": true, + "description": "Emoji name without colons" + } + ], + "outputs": [ + { + "name": "result", + "type": "Data", + "required": true, + "description": "ok" + } + ], + "source": "slack-reactions-add" + }, + "auth_mode": "bot_token_install", + "identity": "bot", + "identity_notes": "Reaction is attributed to the bot user.", + "scopes": [ + { + "scope": "reactions:write", + "classification": "non_sensitive", + "provider_classification": "Bot token scope", + "source": "slack-scopes", + "role": "required" + } + ], + "consent": "user", + "consent_notes": "A workspace installation by a member (or an owner where app approval is enabled) grants the bot scopes; the bot token belongs to the workspace, not the installing user.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Messages in channels the bot can see.", + "tenant": "One workspace per installation.", + "source": "slack-reactions-add" + }, + "deployment_contexts": { + "hosted": "app_install_redirect", + "self_managed": "app_install_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API reactions.add (GA).", + "substrate_source": "slack-reactions-add", + "rate_limit": { + "summary": "reactions.add is Tier 3 (50+ per minute per workspace per app); errors already_reacted and too_many_reactions.", + "source": "slack-reactions-add", + "confidence": "high" + }, + "verification_dependencies": [] + }, + { + "action_id": "slack.bot.list_channel_members", + "display_name": "Slack: List Channel Members (as app)", + "component_class": "SlackListChannelMembersComponent", + "decision": "include", + "confidence": "high", + "rationale": "conversations.members returns user ids; users:read is needed only if the action resolves display names. Included in wave 1 by the release owner on 2026-09-01.", + "schema": { + "inputs": [ + { + "name": "channel", + "type": "str", + "required": true + }, + { + "name": "channel_is_private", + "type": "bool", + "required": false, + "description": "Requests groups:read for a private channel" + }, + { + "name": "limit", + "type": "int", + "required": false + }, + { + "name": "cursor", + "type": "str", + "required": false + }, + { + "name": "resolve_names", + "type": "bool", + "required": false, + "description": "Calls users.info per id; needs users:read" + } + ], + "outputs": [ + { + "name": "members", + "type": "list[Data]", + "required": true + }, + { + "name": "next_cursor", + "type": "str", + "required": false + } + ], + "source": "slack-conversations-members" + }, + "auth_mode": "bot_token_install", + "identity": "bot", + "identity_notes": "Bot must be a member of private channels to list them.", + "scopes": [ + { + "scope": "channels:read", + "classification": "non_sensitive", + "provider_classification": "Bot token scope", + "source": "slack-scopes", + "role": "required" + }, + { + "scope": "groups:read", + "classification": "non_sensitive", + "provider_classification": "Bot token scope", + "source": "slack-scopes", + "role": "optional", + "condition": { + "kind": "input_truthy", + "input": "channel_is_private" + } + }, + { + "scope": "users:read", + "classification": "non_sensitive", + "provider_classification": "Bot token scope", + "source": "slack-scopes", + "role": "optional", + "condition": { + "kind": "input_truthy", + "input": "resolve_names" + }, + "notes": "Only when resolving display names." + } + ], + "consent": "user", + "consent_notes": "A workspace installation by a member (or an owner where app approval is enabled) grants the bot scopes; the bot token belongs to the workspace, not the installing user.", + "consent_source": "slack-app-approval", + "reach": { + "resource": "Membership of public channels and private channels the bot belongs to.", + "tenant": "One workspace per installation.", + "source": "slack-conversations-members" + }, + "deployment_contexts": { + "hosted": "app_install_redirect", + "self_managed": "app_install_redirect", + "headless": "manual_token" + }, + "refresh": { + "behavior": "Slack OAuth tokens do not expire by default. If the app opts into token rotation (irreversible once enabled), access tokens expire every 12 hours (expires_in 43,200) and are refreshed with oauth.v2.access grant_type=refresh_token; refresh tokens are single-use and revoked after a short grace period; rotation applies to both bot and user tokens.", + "source": "slack-token-rotation" + }, + "revocation": { + "behavior": "The app revokes its own token with auth.revoke (Tier 3). A Workspace Owner removes the app under Manage apps, which revokes its tokens; an individual member revokes their own authorization under the app's Authorized members list.", + "source": "slack-auth-revoke" + }, + "substrate": "rest", + "substrate_ga_status": "ga", + "substrate_notes": "Slack Web API conversations.members (GA).", + "substrate_source": "slack-conversations-members", + "rate_limit": { + "summary": "conversations.members is Tier 4 (100+ per minute per workspace per app); cursor pagination.", + "source": "slack-conversations-members", + "confidence": "high" + }, + "verification_dependencies": [] + } + ] +} diff --git a/design/dedicated-integrations/schema/capability_matrix.schema.json b/design/dedicated-integrations/schema/capability_matrix.schema.json new file mode 100644 index 000000000000..62d8fb324aa5 --- /dev/null +++ b/design/dedicated-integrations/schema/capability_matrix.schema.json @@ -0,0 +1,267 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.langflow.org/design/dedicated-integrations/capability-matrix-v1.json", + "title": "Dedicated Integrations wave-1 capability matrix", + "description": "One provider's wave-1 capability matrix for the INT-1 discovery gate. Hand-written for the gate; INT-3 lifts the enums into Pydantic under src/lfx/src/lfx/extension/ and regenerates this file. The enum values here are asserted equal to VALID_VALUES in scripts/ci/check_capability_matrices.py.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", "provider", "display_name", "bundle", "wave", "max_included_actions", "verified_on", + "oauth_app_owner_by_context", "oauth_client_type_by_context", "substrate_decision", + "restricted_scope_decisions", "sources", + "verification_programs", "actions" + ], + "properties": { + "schema_version": { "const": 1 }, + "provider": { "$ref": "#/$defs/provider" }, + "display_name": { "type": "string", "minLength": 1 }, + "bundle": { + "type": "object", + "additionalProperties": false, + "required": ["extension_id", "bundle_name", "distribution"], + "properties": { + "extension_id": { "type": "string", "minLength": 1 }, + "bundle_name": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "distribution": { "type": "string", "minLength": 1 } + } + }, + "wave": { "const": 1 }, + "max_included_actions": { "type": "integer", "minimum": 1, "maximum": 8 }, + "verified_on": { "$ref": "#/$defs/iso_date" }, + "oauth_app_owner_by_context": { + "type": "object", + "additionalProperties": false, + "required": ["hosted", "self_managed", "desktop", "headless"], + "properties": { + "hosted": { "$ref": "#/$defs/oauth_app_owner" }, + "self_managed": { "$ref": "#/$defs/oauth_app_owner" }, + "desktop": { "$ref": "#/$defs/oauth_app_owner" }, + "headless": { "$ref": "#/$defs/oauth_app_owner" } + } + }, + "oauth_client_type_by_context": { + "type": "object", + "additionalProperties": false, + "required": ["hosted", "self_managed", "desktop", "headless"], + "properties": { + "hosted": { "$ref": "#/$defs/oauth_client_type" }, + "self_managed": { "$ref": "#/$defs/oauth_client_type" }, + "desktop": { "$ref": "#/$defs/oauth_client_type" }, + "headless": { "$ref": "#/$defs/oauth_client_type" } + } + }, + "substrate_decision": { + "type": "object", + "additionalProperties": false, + "required": ["chosen", "decision_record"], + "properties": { + "chosen": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/substrate" } }, + "decision_record": { "$ref": "#/$defs/decision_record_path" } + } + }, + "restricted_scope_decisions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "decision", "rationale", "decision_record"], + "properties": { + "scope": { "type": "string", "minLength": 1 }, + "decision": { "$ref": "#/$defs/restricted_scope_decision" }, + "rationale": { "type": "string", "minLength": 1 }, + "decision_record": { "$ref": "#/$defs/decision_record_path" } + } + } + }, + "sources": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "additionalProperties": { "$ref": "#/$defs/source" } + }, + "verification_programs": { + "type": "object", + "propertyNames": { "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "additionalProperties": { "$ref": "#/$defs/verification_program" } + }, + "actions": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/action" } } + }, + "$defs": { + "iso_date": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }, + "source_id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, + "decision_record_path": { "type": "string", "pattern": "^decisions/[a-z0-9-]+\\.md$" }, + "provider": { "enum": ["google", "microsoft", "slack"] }, + "decision": { "enum": ["include", "exclude", "defer"] }, + "confidence": { "enum": ["high", "medium", "low"] }, + "substrate": { "enum": ["sdk", "rest", "mcp"] }, + "substrate_ga_status": { "enum": ["ga", "preview", "developer_preview", "beta", "deprecated"] }, + "identity": { "enum": ["user_delegated", "bot", "service"] }, + "scope_role": { "enum": ["required", "optional", "alternative"] }, + "auth_mode": { + "enum": [ + "oauth2_authorization_code", "oauth2_client_credentials", "oauth2_device_code", "service_account", + "service_account_domain_wide_delegation", "bot_token_install", "api_key" + ] + }, + "classification": { "enum": ["non_sensitive", "sensitive", "restricted"] }, + "consent": { "enum": ["user", "admin", "both"] }, + "callback": { + "enum": ["server_redirect", "loopback_redirect", "device_code", "app_install_redirect", "manual_token", "none"] + }, + "restricted_scope_decision": { "enum": ["avoid", "accept_with_casa", "accept_exempt", "defer"] }, + "oauth_app_owner": { "enum": ["langflow", "customer", "either"] }, + "oauth_client_type": { "enum": ["confidential", "public", "external"] }, + "scope_condition_kind": { "enum": ["input_present", "input_truthy"] }, + "source_kind": { "enum": ["provider_docs", "provider_changelog", "provider_console", "mcp_tools_list"] }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["url", "title", "kind", "verified_on"], + "properties": { + "url": { "type": "string", "pattern": "^https://" }, + "title": { "type": "string", "minLength": 1 }, + "kind": { "$ref": "#/$defs/source_kind" }, + "verified_on": { "$ref": "#/$defs/iso_date" }, + "notes": { "type": "string" } + } + }, + "verification_program": { + "type": "object", + "additionalProperties": false, + "required": ["description", "source", "blocking_for_contexts"], + "properties": { + "description": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source_id" }, + "blocking_for_contexts": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["hosted", "self_managed", "desktop", "headless"] } + }, + "lead_time": { "type": "string" }, + "recurring": { "type": "string" } + } + }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "classification", "provider_classification", "source"], + "properties": { + "scope": { "type": "string", "minLength": 1 }, + "classification": { "$ref": "#/$defs/classification" }, + "provider_classification": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source_id" }, + "role": { "$ref": "#/$defs/scope_role" }, + "condition": { "$ref": "#/$defs/scope_condition" }, + "notes": { "type": "string" } + } + }, + "scope_condition": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "input"], + "properties": { + "kind": { "$ref": "#/$defs/scope_condition_kind" }, + "input": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" } + } + }, + "sourced_text": { + "type": "object", + "additionalProperties": false, + "required": ["behavior", "source"], + "properties": { + "behavior": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source_id" } + } + }, + "io_field": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, + "type": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "description": { "type": "string" } + } + }, + "action": { + "type": "object", + "additionalProperties": false, + "required": [ + "action_id", "display_name", "component_class", "decision", "rationale", "confidence", "identity", + "auth_mode", "substrate", "substrate_ga_status", "deployment_contexts", "scopes" + ], + "properties": { + "action_id": { "type": "string", "pattern": "^(google|microsoft|slack)\\.[a-z0-9_]+\\.[a-z0-9_]+$" }, + "display_name": { "type": "string", "minLength": 1 }, + "component_class": { "type": "string", "pattern": "^[A-Z][A-Za-z0-9]+Component$" }, + "decision": { "$ref": "#/$defs/decision" }, + "rationale": { "type": "string", "minLength": 1 }, + "confidence": { "$ref": "#/$defs/confidence" }, + "open_questions": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["inputs", "outputs", "source"], + "properties": { + "inputs": { "type": "array", "items": { "$ref": "#/$defs/io_field" } }, + "outputs": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/io_field" } }, + "source": { "$ref": "#/$defs/source_id" } + } + }, + "auth_mode": { "$ref": "#/$defs/auth_mode" }, + "identity": { "$ref": "#/$defs/identity" }, + "identity_notes": { "type": "string" }, + "scopes": { "type": "array", "items": { "$ref": "#/$defs/scope" } }, + "consent": { "$ref": "#/$defs/consent" }, + "consent_notes": { "type": "string" }, + "consent_source": { "$ref": "#/$defs/source_id" }, + "reach": { + "type": "object", + "additionalProperties": false, + "required": ["resource", "tenant", "source"], + "properties": { + "resource": { "type": "string", "minLength": 1 }, + "tenant": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source_id" } + } + }, + "deployment_contexts": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "properties": { + "hosted": { "$ref": "#/$defs/callback" }, + "self_managed": { "$ref": "#/$defs/callback" }, + "desktop": { "$ref": "#/$defs/callback" }, + "headless": { "$ref": "#/$defs/callback" } + } + }, + "refresh": { "$ref": "#/$defs/sourced_text" }, + "revocation": { "$ref": "#/$defs/sourced_text" }, + "substrate": { "$ref": "#/$defs/substrate" }, + "substrate_ga_status": { "$ref": "#/$defs/substrate_ga_status" }, + "substrate_notes": { "type": "string" }, + "substrate_source": { "$ref": "#/$defs/source_id" }, + "rate_limit": { + "type": "object", + "additionalProperties": false, + "required": ["summary", "source"], + "properties": { + "summary": { "type": "string", "minLength": 1 }, + "source": { "$ref": "#/$defs/source_id" }, + "confidence": { "$ref": "#/$defs/confidence" } + } + }, + "verification_dependencies": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/source_id" } } + }, + "if": { "properties": { "decision": { "const": "include" } } }, + "then": { + "required": [ + "schema", "consent", "consent_source", "reach", "refresh", "revocation", "substrate_source", + "rate_limit", "verification_dependencies" + ] + } + } + } +} diff --git a/design/dedicated-integrations/triggers-deferred.md b/design/dedicated-integrations/triggers-deferred.md new file mode 100644 index 000000000000..c83366c031ea --- /dev/null +++ b/design/dedicated-integrations/triggers-deferred.md @@ -0,0 +1,57 @@ +# Triggers and webhooks: deferred track + +Status: deferred +Owners (sign-off roles): release owner, platform owner +Last verified: 2026-09-01 + +## What this record is + +The 1.13 plan separates actions from triggers (Scope Boundary 1). Persistent listeners, polling, durable delivery, +replay, deployment binding, conversation correlation, and trigger-driven pause/resume are not 1.13 requirements. +The discovery gate's exit criterion "fold in the trigger/webhook findings as a separate, deferred track" is met by +this record, which carries the findings available in the governing plan, fixes the boundary, and states what still +needs provider-specific discovery. It does not claim that the deferred provider transport investigation is done. + +## Findings carried from the governing plan + +- Request/response actions and persistent triggers are separate lifecycle and delivery products. No wave-1 action + may open a listener, create a subscription, or imply durable event delivery. +- Persistent listeners, polling, durable delivery, replay, deployment binding, conversation correlation, and + trigger-driven pause/resume remain deferred. +- The executing-identity rule is binding for future triggers: interactive user connections resolve only for the + owner or an explicit share; flow-owner, deployment-owner, and other non-interactive execution may use a user + connection only with that connection's explicit opt-in; anonymous execution never resolves a user connection. +- OAuth for an unauthenticated public-flow caller is a separate initiative. It requires one-time state and PKCE, + tenant and execution binding, and an explicit end-user identity model rather than borrowing the flow owner's + connection. +- Provider event transports must be discovered independently and must not be pre-empted by fields added for the + action release. + +## What remains for deferred discovery + +- Provider event sources: Gmail push notifications (Pub/Sub), Microsoft Graph change notifications and + subscriptions, Slack Events API and Socket Mode. +- Delivery semantics: at-least-once vs exactly-once, replay windows, dead-lettering. +- Binding a trigger to a deployment or flow version, and correlating a triggered run to a conversation. +- Provider subscription ownership, renewal, public-ingress requirements, and tenant-specific rate limits. + +## Interaction with the actions release + +Wave-1 actions must not pre-empt trigger design: no action component may open a long-lived listener, and the +connection record must not grow trigger-specific fields in 1.13. + +## Re-open trigger + +- A written trigger/webhook findings document exists, or +- A customer commitment names a trigger-driven flow for a dated release, or +- Two of the three providers' event-delivery mechanisms are confirmed usable from a self-managed instance without + a public ingress (otherwise the hosted-only constraint changes the design). + +Re-verify by: the 1.14 planning gate. + +## Sign-off + +| Role | Name | Date | PR | +|------|------|------|----| +| platform owner | | | | +| release owner | Eric Hare | 2026-09-01 | #14906 (confirmed in the planning session) | diff --git a/scripts/ci/check_capability_matrices.py b/scripts/ci/check_capability_matrices.py new file mode 100755 index 000000000000..eff62e5b9a7a --- /dev/null +++ b/scripts/ci/check_capability_matrices.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +"""Validate the Dedicated Integrations wave-1 capability matrices. + +The matrices under ``design/dedicated-integrations/matrices/`` are the INT-1 +discovery-gate contract: at most eight included actions per provider, every +scope classified and sourced, every restricted scope decided, and every +substrate choice recorded in a decision record. ``--require-accepted`` is the +gate-close mode: every referenced decision record must carry +``Status: accepted`` and every declared owner must have a completed signature. + +Every matrix is first validated against ``schema/capability_matrix.schema.json`` +(Draft 2020-12, via ``jsonschema``), then the gate rules that a schema cannot +express are applied. Every scope on an included action carries a ``role`` +(``required``, ``optional``, or ``alternative``) so the manifest's +``required_scopes`` and conditional scope requirements can be lifted +mechanically, and at least one scope is required. ``--require-accepted`` walks every record under +``decisions/`` (``TEMPLATE.md`` aside), not only the ones a matrix references. + +Sign-off coverage is checked alongside the matrices: every record under the +design directory that declares ``Owners (sign-off roles):`` must be listed in +the README sign-off table under each of those roles, and its own ``## Sign-off`` +table must carry a row per declared role. +""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any + +try: + from jsonschema import Draft202012Validator +except ImportError: # pragma: no cover - exercised only when the dependency is missing + Draft202012Validator = None # type: ignore[assignment,misc] + +REPO_ROOT = Path(__file__).resolve().parents[2] +DESIGN_ROOT = REPO_ROOT / "design" / "dedicated-integrations" +DEFAULT_MATRIX_DIR = DESIGN_ROOT / "matrices" +SCHEMA_PATH = DESIGN_ROOT / "schema" / "capability_matrix.schema.json" + +REQUIRED_PROVIDERS = frozenset({"google", "microsoft", "slack"}) +DEFAULT_MAX_INCLUDED = 8 +DEPLOYMENT_CONTEXTS = frozenset({"hosted", "self_managed", "desktop", "headless"}) + +ACTION_ID_RE = re.compile(r"^(google|microsoft|slack)\.[a-z0-9_]+\.[a-z0-9_]+$") +COMPONENT_CLASS_RE = re.compile(r"^[A-Z][A-Za-z0-9]+Component$") +SOURCE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") +STATUS_RE = re.compile(r"^Status:\s*(draft|proposed|accepted|superseded)\b", re.MULTILINE) +DECISION_HEADING_RE = re.compile(r"^## Decision\s*$", re.MULTILINE) +OWNERS_RE = re.compile(r"^Owners \(sign-off roles\):\s*(.+?)\s*$", re.MULTILINE) +SIGN_OFF_HEADING = "## Sign-off" +ROLE_RE = re.compile(r"^[A-Za-z][A-Za-z-]* owner$") +SIGNATURE_FIELD_COUNT = 3 +MIN_SIGN_OFF_ROW_CELLS = 4 +# The release owner's acceptance is the Status line itself; the README row for that role says "every record". +ROLES_COVERING_EVERY_RECORD = frozenset({"release owner"}) +# Files that carry an Owners line but are not sign-off records themselves. +SIGN_OFF_EXEMPT_FILES = frozenset({"README.md", "TEMPLATE.md"}) + +VALID_VALUES: dict[str, frozenset[str]] = { + "provider": REQUIRED_PROVIDERS, + "decision": frozenset({"include", "exclude", "defer"}), + "confidence": frozenset({"high", "medium", "low"}), + "substrate": frozenset({"sdk", "rest", "mcp"}), + "substrate_ga_status": frozenset({"ga", "preview", "developer_preview", "beta", "deprecated"}), + "identity": frozenset({"user_delegated", "bot", "service"}), + "auth_mode": frozenset( + { + "oauth2_authorization_code", + "oauth2_client_credentials", + "oauth2_device_code", + "service_account", + "service_account_domain_wide_delegation", + "bot_token_install", + "api_key", + } + ), + "classification": frozenset({"non_sensitive", "sensitive", "restricted"}), + "scope_role": frozenset({"required", "optional", "alternative"}), + "consent": frozenset({"user", "admin", "both"}), + "callback": frozenset( + {"server_redirect", "loopback_redirect", "device_code", "app_install_redirect", "manual_token", "none"} + ), + "restricted_scope_decision": frozenset({"avoid", "accept_with_casa", "accept_exempt", "defer"}), + "oauth_app_owner": frozenset({"langflow", "customer", "either"}), + "oauth_client_type": frozenset({"confidential", "public", "external"}), + "scope_condition_kind": frozenset({"input_present", "input_truthy"}), + "source_kind": frozenset({"provider_docs", "provider_changelog", "provider_console", "mcp_tools_list"}), +} + +REQUIRED_TOP_LEVEL = frozenset( + { + "schema_version", + "provider", + "display_name", + "bundle", + "wave", + "max_included_actions", + "verified_on", + "oauth_app_owner_by_context", + "oauth_client_type_by_context", + "substrate_decision", + "restricted_scope_decisions", + "sources", + "verification_programs", + "actions", + } +) +REQUIRED_ACTION_FIELDS = frozenset( + { + "action_id", + "display_name", + "component_class", + "decision", + "rationale", + "confidence", + "identity", + "auth_mode", + "substrate", + "substrate_ga_status", + "deployment_contexts", + "scopes", + } +) +# An included action must be fully documented; excluded and deferred candidates only need the identity fields. +REQUIRED_INCLUDE_FIELDS = frozenset( + { + "schema", + "consent", + "consent_source", + "reach", + "refresh", + "revocation", + "substrate_source", + "rate_limit", + "verification_dependencies", + } +) +CONDITIONAL_SCOPE_ROLES = frozenset({"optional", "alternative"}) +SOURCED_BLOCKS = ("schema", "reach", "refresh", "revocation", "rate_limit") +SOURCED_SCALARS = ("consent_source", "substrate_source") + + +def _parse_date(raw: Any) -> date | None: + if not isinstance(raw, str): + return None + try: + return date.fromisoformat(raw) + except ValueError: + return None + + +def _check_date(raw: Any, label: str, errors: list[str]) -> None: + parsed = _parse_date(raw) + if parsed is None: + errors.append(f"{label} must be an ISO date (YYYY-MM-DD), got {raw!r}") + elif parsed > datetime.now(tz=UTC).date(): + errors.append(f"{label} {raw!r} is in the future") + + +def _check_enum(value: Any, dimension: str, label: str, errors: list[str]) -> None: + if value not in VALID_VALUES[dimension]: + errors.append(f"{label} has unknown {dimension} {value!r}") + + +def _check_source_ref(source_id: Any, sources: dict[str, Any], label: str, errors: list[str]) -> None: + if not isinstance(source_id, str) or source_id not in sources: + errors.append(f"{label} references unknown source {source_id!r}") + + +def _schema_errors(matrix: dict[str, Any], schema_path: Path = SCHEMA_PATH) -> list[str]: + """Validate the matrix against the published JSON Schema; a missing validator is an error, not a skip.""" + if Draft202012Validator is None: + return ["jsonschema is not installed; install it (the CI Scripts Tests workflow does) to validate matrices"] + try: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [f"could not load schema {schema_path}: {exc}"] + found = sorted(Draft202012Validator(schema).iter_errors(matrix), key=lambda error: list(map(str, error.path))) + return [f"schema: {'/'.join(str(part) for part in error.path) or ''}: {error.message}" for error in found] + + +def _validate_sources(matrix: dict[str, Any], errors: list[str]) -> dict[str, Any]: + sources = matrix.get("sources") + if not isinstance(sources, dict) or not sources: + errors.append("sources must be a non-empty object keyed by source id") + return {} + for source_id, source in sources.items(): + label = f"source {source_id!r}" + if not SOURCE_ID_RE.match(source_id): + errors.append(f"{label} id must be kebab-case") + if not isinstance(source, dict): + errors.append(f"{label} must be an object") + continue + errors.extend( + f"{label} is missing {key!r}" for key in ("url", "title", "kind", "verified_on") if key not in source + ) + url = source.get("url", "") + if not isinstance(url, str) or not url.startswith("https://"): + errors.append(f"{label} url must start with https://") + if "kind" in source: + _check_enum(source["kind"], "source_kind", label, errors) + if "verified_on" in source: + _check_date(source["verified_on"], f"{label} verified_on", errors) + return sources + + +def _validate_verification_programs(matrix: dict[str, Any], sources: dict[str, Any], errors: list[str]) -> set[str]: + programs = matrix.get("verification_programs") + if not isinstance(programs, dict): + errors.append("verification_programs must be an object keyed by program id") + return set() + for program_id, program in programs.items(): + label = f"verification program {program_id!r}" + if not isinstance(program, dict): + errors.append(f"{label} must be an object") + continue + if not str(program.get("description", "")).strip(): + errors.append(f"{label} needs a description") + _check_source_ref(program.get("source"), sources, label, errors) + contexts = program.get("blocking_for_contexts", []) + if not isinstance(contexts, list) or not set(contexts) <= DEPLOYMENT_CONTEXTS: + errors.append(f"{label} blocking_for_contexts must be a list drawn from {sorted(DEPLOYMENT_CONTEXTS)}") + return set(programs) + + +def _decision_record_errors(record: Any, design_root: Path, label: str, *, require_accepted: bool) -> list[str]: + if not isinstance(record, str) or not record: + return [f"{label} must name a decision record path relative to design/dedicated-integrations"] + path = design_root / record + if not path.is_file(): + return [f"{label} decision record {record!r} does not exist"] + text = path.read_text(encoding="utf-8") + status = STATUS_RE.search(text) + errors: list[str] = [] + if status is None: + errors.append(f"{label} decision record {record!r} lacks a 'Status: draft|proposed|accepted|superseded' line") + elif require_accepted and status.group(1) != "accepted": + errors.append(f"{label} decision record {record!r} is {status.group(1)}, not accepted") + if DECISION_HEADING_RE.search(text) is None: + errors.append(f"{label} decision record {record!r} lacks a '## Decision' heading") + return errors + + +def _validate_top_level(matrix: dict[str, Any], stem: str, errors: list[str]) -> None: + missing = REQUIRED_TOP_LEVEL - set(matrix) + if missing: + errors.append(f"matrix is missing top-level fields {sorted(missing)}") + if matrix.get("schema_version") != 1: + errors.append("schema_version must be 1") + provider = matrix.get("provider") + _check_enum(provider, "provider", "matrix", errors) + if provider != stem: + errors.append(f"provider {provider!r} does not match file name {stem!r}") + bundle = matrix.get("bundle", {}) + if not isinstance(bundle, dict) or not {"extension_id", "bundle_name", "distribution"} <= set(bundle): + errors.append("bundle must declare extension_id, bundle_name, and distribution") + if matrix.get("wave") != 1: + errors.append("wave must be 1 for this gate") + max_included = matrix.get("max_included_actions") + if not isinstance(max_included, int) or not 0 < max_included <= DEFAULT_MAX_INCLUDED: + errors.append(f"max_included_actions must be an integer between 1 and {DEFAULT_MAX_INCLUDED}") + _check_date(matrix.get("verified_on"), "verified_on", errors) + owners = matrix.get("oauth_app_owner_by_context") + if not isinstance(owners, dict) or set(owners) != DEPLOYMENT_CONTEXTS: + errors.append(f"oauth_app_owner_by_context must cover exactly {sorted(DEPLOYMENT_CONTEXTS)}") + else: + for context, owner in owners.items(): + _check_enum(owner, "oauth_app_owner", f"oauth_app_owner_by_context.{context}", errors) + client_types = matrix.get("oauth_client_type_by_context") + if not isinstance(client_types, dict) or set(client_types) != DEPLOYMENT_CONTEXTS: + errors.append(f"oauth_client_type_by_context must cover exactly {sorted(DEPLOYMENT_CONTEXTS)}") + else: + for context, client_type in client_types.items(): + _check_enum( + client_type, + "oauth_client_type", + f"oauth_client_type_by_context.{context}", + errors, + ) + + +def _validate_substrate_decision( + matrix: dict[str, Any], design_root: Path, errors: list[str], *, require_accepted: bool +) -> set[str]: + decision = matrix.get("substrate_decision") + if not isinstance(decision, dict): + errors.append("substrate_decision must be an object with chosen[] and decision_record") + return set() + chosen = decision.get("chosen") + if not isinstance(chosen, list) or not chosen: + errors.append("substrate_decision.chosen must be a non-empty list") + chosen = [] + for substrate in chosen: + _check_enum(substrate, "substrate", "substrate_decision.chosen", errors) + errors.extend( + _decision_record_errors( + decision.get("decision_record"), design_root, "substrate_decision", require_accepted=require_accepted + ) + ) + return set(chosen) + + +def _validate_restricted_decisions( + matrix: dict[str, Any], design_root: Path, errors: list[str], *, require_accepted: bool +) -> dict[str, str]: + entries = matrix.get("restricted_scope_decisions") + if not isinstance(entries, list): + errors.append("restricted_scope_decisions must be a list") + return {} + decisions: dict[str, str] = {} + for entry in entries: + scope = entry.get("scope") if isinstance(entry, dict) else None + label = f"restricted scope decision for {scope!r}" + if not isinstance(entry, dict) or not isinstance(scope, str) or not scope: + errors.append("every restricted_scope_decisions entry needs a scope") + continue + if scope in decisions: + errors.append(f"{label} is declared more than once") + _check_enum(entry.get("decision"), "restricted_scope_decision", label, errors) + if not str(entry.get("rationale", "")).strip(): + errors.append(f"{label} needs a written rationale") + errors.extend( + _decision_record_errors(entry.get("decision_record"), design_root, label, require_accepted=require_accepted) + ) + decisions[scope] = str(entry.get("decision")) + return decisions + + +def _check_scope_role( + scope: dict[str, Any], + scope_label: str, + *, + included: bool, + input_names: set[str], + errors: list[str], +) -> str | None: + """Enforce the role grammar: included actions tag every scope; conditional roles say when they apply.""" + role = scope.get("role") + if role is None: + if included: + errors.append( + f"{scope_label} must declare a role (required, optional, or alternative) on an included action" + ) + return None + _check_enum(role, "scope_role", scope_label, errors) + condition = scope.get("condition") + if role in CONDITIONAL_SCOPE_ROLES and not isinstance(condition, dict): + errors.append(f"{scope_label} is {role} and must state the condition under which it is requested") + elif role == "required" and condition is not None: + errors.append(f"{scope_label} is required and must not carry a condition; make it optional or alternative") + elif isinstance(condition, dict): + kind = condition.get("kind") + if kind in {"input_present", "input_truthy"} and condition.get("input") not in input_names: + errors.append(f"{scope_label} condition references unknown action input {condition.get('input')!r}") + return str(role) + + +def _validate_scopes( + action: dict[str, Any], sources: dict[str, Any], label: str, errors: list[str], *, included: bool +) -> list[str]: + scopes = action.get("scopes") + if not isinstance(scopes, list): + errors.append(f"{label} scopes must be a list") + return [] + restricted: list[str] = [] + roles: list[str | None] = [] + schema = action.get("schema") + inputs = schema.get("inputs", []) if isinstance(schema, dict) else [] + input_names = {field["name"] for field in inputs if isinstance(field, dict) and isinstance(field.get("name"), str)} + for scope in scopes: + if not isinstance(scope, dict) or not isinstance(scope.get("scope"), str) or not scope["scope"]: + errors.append(f"{label} has a scope entry without a scope string") + continue + scope_label = f"{label} scope {scope['scope']!r}" + if "classification" not in scope: + errors.append(f"{scope_label} is not classified") + else: + _check_enum(scope["classification"], "classification", scope_label, errors) + if scope["classification"] == "restricted": + restricted.append(scope["scope"]) + if not str(scope.get("provider_classification", "")).strip(): + errors.append(f"{scope_label} must record the provider's own classification term") + _check_source_ref(scope.get("source"), sources, scope_label, errors) + roles.append( + _check_scope_role( + scope, + scope_label, + included=included, + input_names=input_names, + errors=errors, + ) + ) + if included and "required" not in roles: + errors.append(f"{label} is included but declares no required scope") + return restricted + + +def _validate_sourced_claims( + action: dict[str, Any], sources: dict[str, Any], programs: set[str], label: str, errors: list[str] +) -> None: + for block in SOURCED_BLOCKS: + if block not in action: + continue + value = action[block] + if not isinstance(value, dict): + errors.append(f"{label} {block} must be an object carrying a source") + continue + _check_source_ref(value.get("source"), sources, f"{label} {block}", errors) + if block == "rate_limit" and "confidence" in value: + _check_enum(value["confidence"], "confidence", f"{label} rate_limit", errors) + for scalar in SOURCED_SCALARS: + if scalar in action: + _check_source_ref(action[scalar], sources, f"{label} {scalar}", errors) + errors.extend( + f"{label} references unknown verification program {program_id!r}" + for program_id in action.get("verification_dependencies", []) + if program_id not in programs + ) + + +def _validate_action( + action: dict[str, Any], + *, + provider: str, + sources: dict[str, Any], + programs: set[str], + chosen_substrates: set[str], + restricted_decisions: dict[str, str], + errors: list[str], +) -> None: + action_id = action.get("action_id", "") + label = f"{provider}:{action_id}" + missing = REQUIRED_ACTION_FIELDS - set(action) + if missing: + errors.append(f"{label} is missing {sorted(missing)}") + return + if not ACTION_ID_RE.match(str(action_id)) or not str(action_id).startswith(f"{provider}."): + errors.append(f"{label} action_id must look like '{provider}..'") + if not COMPONENT_CLASS_RE.match(str(action["component_class"])): + errors.append(f"{label} component_class must be a *Component class name") + for dimension in ("decision", "confidence", "identity", "auth_mode", "substrate", "substrate_ga_status"): + _check_enum(action[dimension], dimension, label, errors) + if not str(action["rationale"]).strip(): + errors.append(f"{label} needs a rationale") + + contexts = action["deployment_contexts"] + if not isinstance(contexts, dict) or not contexts or not set(contexts) <= DEPLOYMENT_CONTEXTS: + errors.append(f"{label} deployment_contexts must be a non-empty map drawn from {sorted(DEPLOYMENT_CONTEXTS)}") + else: + for context, callback in contexts.items(): + _check_enum(callback, "callback", f"{label} deployment_contexts.{context}", errors) + + decision = action["decision"] + if decision == "include": + missing_include = REQUIRED_INCLUDE_FIELDS - set(action) + if missing_include: + errors.append(f"{label} is included but missing {sorted(missing_include)}") + if action["substrate"] not in chosen_substrates: + errors.append(f"{label} uses substrate {action['substrate']!r} outside substrate_decision.chosen") + if "consent" in action: + _check_enum(action["consent"], "consent", label, errors) + if action["substrate"] == "mcp" and action["substrate_ga_status"] != "ga" and action["confidence"] == "high": + errors.append(f"{label} cannot be high confidence on a non-GA MCP substrate") + if action["confidence"] == "low" and not action.get("open_questions"): + errors.append(f"{label} is low confidence and must list open_questions") + + restricted = _validate_scopes(action, sources, label, errors, included=decision == "include") + if decision in {"include", "defer"}: + for scope in restricted: + if scope not in restricted_decisions: + errors.append(f"{label} carries restricted scope {scope!r} with no restricted_scope_decisions entry") + elif decision == "include" and restricted_decisions[scope] in {"avoid", "defer"}: + errors.append( + f"{label} is included but its restricted scope {scope!r} is decided " + f"{restricted_decisions[scope]!r}; that is a contradiction" + ) + _validate_sourced_claims(action, sources, programs, label, errors) + + +def validate_matrix(matrix_path: Path, *, require_accepted: bool = False) -> list[str]: + """Return reader-friendly contract errors for one provider matrix; an empty list means complete.""" + try: + matrix = json.loads(matrix_path.read_text(encoding="utf-8")) + except OSError as exc: + return [f"could not read capability matrix {matrix_path}: {exc}"] + except json.JSONDecodeError as exc: + return [f"capability matrix {matrix_path} is not valid JSON: {exc}"] + if not isinstance(matrix, dict): + return [f"capability matrix {matrix_path} must be a JSON object"] + + design_root = matrix_path.resolve().parent.parent + errors: list[str] = _schema_errors(matrix) + _validate_top_level(matrix, matrix_path.stem, errors) + sources = _validate_sources(matrix, errors) + programs = _validate_verification_programs(matrix, sources, errors) + chosen = _validate_substrate_decision(matrix, design_root, errors, require_accepted=require_accepted) + restricted_decisions = _validate_restricted_decisions( + matrix, design_root, errors, require_accepted=require_accepted + ) + + actions = matrix.get("actions") + if not isinstance(actions, list) or not actions: + errors.append("actions must be a non-empty list") + return errors + provider = str(matrix.get("provider")) + seen: set[str] = set() + for action in actions: + if not isinstance(action, dict): + errors.append(f"{provider}: every action must be an object") + continue + action_id = action.get("action_id") + if action_id in seen: + errors.append(f"{provider}:{action_id} is declared more than once") + seen.add(action_id) + _validate_action( + action, + provider=provider, + sources=sources, + programs=programs, + chosen_substrates=chosen, + restricted_decisions=restricted_decisions, + errors=errors, + ) + + included = sum(1 for action in actions if isinstance(action, dict) and action.get("decision") == "include") + max_included = matrix.get("max_included_actions") + if isinstance(max_included, int) and included > max_included: + errors.append(f"{provider} includes {included} actions, exceeding max_included_actions={max_included}") + return errors + + +def validate_decision_records(design_root: Path = DESIGN_ROOT, *, require_accepted: bool = False) -> list[str]: + """Walk every record under decisions/ (TEMPLATE.md aside); gate close requires each one to be accepted.""" + decisions_dir = design_root / "decisions" + if not decisions_dir.is_dir(): + return [f"decision records directory {decisions_dir} does not exist"] + errors: list[str] = [] + for record in sorted(decisions_dir.glob("*.md")): + if record.name in SIGN_OFF_EXEMPT_FILES: + continue + relative = record.relative_to(design_root).as_posix() + errors.extend(_decision_record_errors(relative, design_root, "gate", require_accepted=require_accepted)) + return errors + + +def _table_rows(section: str) -> list[list[str]]: + """Return the cells of every pipe-table body row in a Markdown section (header and rule rows dropped).""" + rows: list[list[str]] = [] + for line in section.splitlines(): + stripped = line.strip() + if not stripped.startswith("|") or re.match(r"^\|\s*-", stripped): + continue + rows.append([cell.strip() for cell in stripped.strip("|").split("|")]) + return rows[1:] + + +def _sign_off_section(text: str) -> str | None: + if SIGN_OFF_HEADING not in text: + return None + return text.split(SIGN_OFF_HEADING, 1)[1].split("\n## ", 1)[0] + + +def _readme_sign_off_rows(design_root: Path, errors: list[str]) -> dict[str, list[str]]: + readme = design_root / "README.md" + if not readme.is_file(): + errors.append(f"sign-off: {readme} does not exist") + return {} + section = _sign_off_section(readme.read_text(encoding="utf-8")) + if section is None: + errors.append("sign-off: README.md lacks a '## Sign-off' table") + return {} + return {row[0]: row for row in _table_rows(section) if len(row) > 1} + + +def _declared_roles(text: str, label: str, errors: list[str]) -> list[str]: + match = OWNERS_RE.search(text) + if match is None: + errors.append(f"sign-off: {label} lacks an 'Owners (sign-off roles):' line") + return [] + roles = [role.strip() for role in match.group(1).split(",") if role.strip()] + errors.extend(f"sign-off: {label} declares malformed role {role!r}" for role in roles if not ROLE_RE.match(role)) + return roles + + +def _validate_completed_signature(row: list[str], label: str, errors: list[str]) -> None: + """Require non-empty Name, Date, and PR cells after the role or record-list columns.""" + signature = row[-SIGNATURE_FIELD_COUNT:] if len(row) >= MIN_SIGN_OFF_ROW_CELLS else [] + if len(signature) != SIGNATURE_FIELD_COUNT or any(not cell.strip() for cell in signature): + errors.append(f"sign-off: {label} must complete Name, Date, and PR") + return + _check_date(signature[1], f"sign-off: {label} Date", errors) + + +def validate_sign_offs(design_root: Path = DESIGN_ROOT, *, require_complete: bool = False) -> list[str]: + """Require every owner role to be tracked; gate close also requires completed signatures.""" + errors: list[str] = [] + readme_rows = _readme_sign_off_rows(design_root, errors) + if errors: + # Without the README table every declared role would fail; one error says what is actually missing. + return errors + records = sorted(path for path in design_root.rglob("*.md") if path.name not in SIGN_OFF_EXEMPT_FILES) + for record in records: + label = record.relative_to(design_root).as_posix() + text = record.read_text(encoding="utf-8") + roles = _declared_roles(text, label, errors) + for role in roles: + if role in ROLES_COVERING_EVERY_RECORD: + if role not in readme_rows: + errors.append(f"sign-off: README.md table has no row for {role!r}") + continue + row = readme_rows.get(role) + if row is None: + errors.append(f"sign-off: README.md table has no row for {role!r}, declared by {label}") + elif f"`{label}`" not in row[1]: + errors.append(f"sign-off: README.md row for {role!r} does not list `{label}`") + section = _sign_off_section(text) + if section is None: + if set(roles) - ROLES_COVERING_EVERY_RECORD: + errors.append(f"sign-off: {label} declares {sorted(roles)} but has no '{SIGN_OFF_HEADING}' table") + continue + table_rows = {row[0]: row for row in _table_rows(section) if row} + table_roles = set(table_rows) + errors.extend(f"sign-off: {label} table lacks a row for {role!r}" for role in roles if role not in table_roles) + errors.extend( + f"sign-off: {label} table row {role!r} is not a declared owner" for role in sorted(table_roles - set(roles)) + ) + if require_complete: + for role in roles: + row = table_rows.get(role) + if row is not None: + _validate_completed_signature(row, f"{label} row {role!r}", errors) + if require_complete: + for role, row in readme_rows.items(): + _validate_completed_signature(row, f"README.md row {role!r}", errors) + return errors + + +def validate_all(matrix_dir: Path = DEFAULT_MATRIX_DIR, *, require_accepted: bool = False) -> list[str]: + """Validate every provider matrix and require one file per wave-1 provider.""" + errors: list[str] = [] + present = {path.stem for path in matrix_dir.glob("*.json")} if matrix_dir.is_dir() else set() + missing = REQUIRED_PROVIDERS - present + if missing: + errors.append(f"missing capability matrices for {sorted(missing)} under {matrix_dir}") + unexpected = present - REQUIRED_PROVIDERS + if unexpected: + errors.append( + f"unexpected capability matrices {sorted(unexpected)}; wave 1 covers {sorted(REQUIRED_PROVIDERS)}" + ) + for stem in sorted(present & REQUIRED_PROVIDERS): + errors.extend(validate_matrix(matrix_dir / f"{stem}.json", require_accepted=require_accepted)) + errors.extend(validate_decision_records(matrix_dir.resolve().parent, require_accepted=require_accepted)) + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--matrix-dir", type=Path, default=DEFAULT_MATRIX_DIR) + parser.add_argument( + "--require-accepted", + action="store_true", + help="gate-close mode: every decision must be accepted and every declared owner signature complete", + ) + args = parser.parse_args() + errors = validate_all(args.matrix_dir, require_accepted=args.require_accepted) + errors.extend(validate_sign_offs(args.matrix_dir.resolve().parent, require_complete=args.require_accepted)) + if errors: + print("Capability matrix validation failed:") + for error in errors: + print(f"- {error}") + return 1 + print("Capability matrices are complete.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_capability_matrices.py b/scripts/ci/test_capability_matrices.py new file mode 100644 index 000000000000..74c6ed838b4c --- /dev/null +++ b/scripts/ci/test_capability_matrices.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import json +import re +import shutil +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from check_capability_matrices import ( + DEFAULT_MATRIX_DIR, + DESIGN_ROOT, + REQUIRED_PROVIDERS, + SCHEMA_PATH, + VALID_VALUES, + validate_all, + validate_decision_records, + validate_matrix, + validate_sign_offs, +) + +CI_SCRIPTS_WORKFLOW = DESIGN_ROOT.parents[1] / ".github" / "workflows" / "ci-scripts-test.yml" + + +def _workflow_pull_request_paths() -> list[str]: + """Read the quoted pull-request path filters without adding a YAML dependency.""" + workflow = CI_SCRIPTS_WORKFLOW.read_text(encoding="utf-8") + assert workflow.count(" paths:\n") == 1, "expected exactly one pull-request paths block" + assert " workflow_dispatch:" in workflow, "expected workflow_dispatch to terminate the paths block" + paths_block = workflow.split(" paths:\n", 1)[1].split(" workflow_dispatch:", 1)[0] + entries = [line.strip().removeprefix("- ") for line in paths_block.splitlines() if line.strip()] + return [json.loads(entry) for entry in entries] + + +def _github_path_matches(path: str, pattern: str) -> bool: + """Match the ``*`` and ``**`` forms used by this workflow's path filters.""" + regex_parts: list[str] = [] + index = 0 + while index < len(pattern): + if pattern[index : index + 2] == "**": + regex_parts.append(".*") + index += 2 + elif pattern[index] == "*": + regex_parts.append("[^/]*") + index += 1 + else: + regex_parts.append(re.escape(pattern[index])) + index += 1 + return re.fullmatch("".join(regex_parts), path) is not None + + +def _copy_design(tmp_path: Path) -> Path: + """Copy the matrices and decision records so a test can corrupt them without touching the repo.""" + root = tmp_path / "dedicated-integrations" + shutil.copytree(DESIGN_ROOT / "matrices", root / "matrices") + shutil.copytree(DESIGN_ROOT / "decisions", root / "decisions") + return root + + +def _copy_design_tree(tmp_path: Path) -> Path: + """Copy the whole design directory (README, records, matrices) for sign-off coverage tests.""" + root = tmp_path / "dedicated-integrations" + shutil.copytree(DESIGN_ROOT, root) + return root + + +def _load(root: Path, provider: str) -> dict: + return json.loads((root / "matrices" / f"{provider}.json").read_text(encoding="utf-8")) + + +def _save(root: Path, provider: str, matrix: dict) -> Path: + path = root / "matrices" / f"{provider}.json" + path.write_text(json.dumps(matrix), encoding="utf-8") + return path + + +def test_capability_matrices_are_complete() -> None: + assert validate_all() == [] + + +def test_every_required_provider_has_a_matrix() -> None: + present = {path.stem for path in DEFAULT_MATRIX_DIR.glob("*.json")} + assert present == set(REQUIRED_PROVIDERS) + + +def test_ci_workflow_watches_design_directory_and_checker() -> None: + workflow_paths = _workflow_pull_request_paths() + canonical_paths = [ + "design/dedicated-integrations/matrices/google.json", + "design/dedicated-integrations/decisions/substrate-google.md", + "scripts/ci/check_capability_matrices.py", + ] + uncovered = [ + path for path in canonical_paths if not any(_github_path_matches(path, pattern) for pattern in workflow_paths) + ] + assert uncovered == [], f"CI scripts checker is not triggered by capability-matrix paths: {uncovered}" + + +def test_schema_enums_match_checker_constants() -> None: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + for dimension, values in VALID_VALUES.items(): + assert dimension in schema["$defs"], f"schema lacks a $defs entry for {dimension}" + assert set(schema["$defs"][dimension]["enum"]) == set(values), f"schema enum drifted for {dimension}" + + +def test_checker_rejects_more_included_actions_than_the_cap(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "microsoft") + matrix["max_included_actions"] = 1 + for action in matrix["actions"][:2]: + action["decision"] = "include" + + errors = validate_matrix(_save(root, "microsoft", matrix)) + + assert any("exceeding max_included_actions=1" in error for error in errors) + + +def test_checker_rejects_cap_above_eight(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "slack") + matrix["max_included_actions"] = 9 + + assert any("between 1 and 8" in error for error in validate_matrix(_save(root, "slack", matrix))) + + +def test_checker_rejects_unclassified_scope(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + matrix["actions"][0]["scopes"][0].pop("classification") + + assert any("is not classified" in error for error in validate_matrix(_save(root, "google", matrix))) + + +def test_checker_rejects_restricted_scope_without_decision(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + # The rule applies to included and deferred rows; an excluded row may carry an undecided restricted scope. + for action in matrix["actions"]: + if any(scope.get("classification") == "restricted" for scope in action["scopes"]): + action["decision"] = "defer" + matrix["restricted_scope_decisions"] = [] + + errors = validate_matrix(_save(root, "google", matrix)) + + assert any("restricted scope" in error and "no restricted_scope_decisions entry" in error for error in errors) + + +def test_checker_rejects_include_on_avoided_restricted_scope(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + restricted = { + scope["scope"] + for action in matrix["actions"] + for scope in action["scopes"] + if scope.get("classification") == "restricted" + } + assert restricted, "fixture needs at least one restricted scope" + target = next(action for action in matrix["actions"] if any(s["scope"] in restricted for s in action["scopes"])) + target["decision"] = "include" + for entry in matrix["restricted_scope_decisions"]: + entry["decision"] = "avoid" + + errors = validate_matrix(_save(root, "google", matrix)) + + assert any("contradiction" in error for error in errors) + + +def _included_action(matrix: dict) -> dict: + return next(action for action in matrix["actions"] if action["decision"] == "include") + + +def test_every_included_scope_carries_a_role() -> None: + for provider in REQUIRED_PROVIDERS: + matrix = _load(DESIGN_ROOT, provider) + for action in matrix["actions"]: + if action["decision"] != "include": + continue + roles = [scope.get("role") for scope in action["scopes"]] + assert None not in roles, f"{action['action_id']} has an untagged scope" + assert "required" in roles, f"{action['action_id']} has no required scope" + + +def test_checker_rejects_included_scope_without_role(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "microsoft") + _included_action(matrix)["scopes"][0].pop("role") + + errors = validate_matrix(_save(root, "microsoft", matrix)) + + assert any("must declare a role" in error for error in errors) + + +def test_checker_rejects_included_action_without_required_scope(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + action = _included_action(matrix) + input_name = action["schema"]["inputs"][0]["name"] + for scope in action["scopes"]: + scope.update({"role": "optional", "condition": {"kind": "input_present", "input": input_name}}) + + errors = validate_matrix(_save(root, "google", matrix)) + + assert any("declares no required scope" in error for error in errors) + + +def test_checker_rejects_conditional_scope_without_condition(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "slack") + scope = _included_action(matrix)["scopes"][0] + scope["role"] = "alternative" + scope.pop("condition", None) + + errors = validate_matrix(_save(root, "slack", matrix)) + + assert any("must state the condition" in error for error in errors) + + +def test_checker_rejects_required_scope_with_condition(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + action = _included_action(matrix) + action["scopes"][0]["condition"] = { + "kind": "input_present", + "input": action["schema"]["inputs"][0]["name"], + } + + errors = validate_matrix(_save(root, "google", matrix)) + + assert any("must not carry a condition" in error for error in errors) + + +def test_schema_rejects_prose_scope_condition(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "microsoft") + action = next(action for action in matrix["actions"] if action["action_id"] == "microsoft.files.list") + conditional_scope = next(scope for scope in action["scopes"] if scope["role"] == "optional") + conditional_scope["condition"] = "drive_id is set" + + errors = validate_matrix(_save(root, "microsoft", matrix)) + + assert any("is not of type 'object'" in error for error in errors) + + +def test_checker_rejects_scope_condition_for_unknown_input(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "microsoft") + action = next(action for action in matrix["actions"] if action["action_id"] == "microsoft.files.list") + conditional_scope = next(scope for scope in action["scopes"] if scope["role"] == "optional") + conditional_scope["condition"]["input"] = "missing_input" + + errors = validate_matrix(_save(root, "microsoft", matrix)) + + assert any("condition references unknown action input 'missing_input'" in error for error in errors) + + +def test_schema_validation_rejects_empty_outputs(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + _included_action(matrix)["schema"]["outputs"] = [] + + errors = validate_matrix(_save(root, "google", matrix)) + + assert any( + error.startswith("schema: actions/") and error.endswith("/schema/outputs: [] should be non-empty") + for error in errors + ) + + +def test_schema_validation_rejects_unknown_action_field(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "slack") + matrix["actions"][0]["scope_notes"] = "typo for notes" + + errors = validate_matrix(_save(root, "slack", matrix)) + + assert any(error.startswith("schema: actions/0:") and "'scope_notes' was unexpected" in error for error in errors) + + +def test_checker_rejects_unsourced_claim(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "slack") + matrix["actions"][0]["scopes"][0]["source"] = "does-not-exist" + + assert any("unknown source 'does-not-exist'" in error for error in validate_matrix(_save(root, "slack", matrix))) + + +def test_checker_rejects_future_verified_on(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + matrix["verified_on"] = "2999-01-01" + + assert any("is in the future" in error for error in validate_matrix(_save(root, "google", matrix))) + + +def test_checker_rejects_low_confidence_without_open_questions(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "microsoft") + matrix["actions"][0]["confidence"] = "low" + matrix["actions"][0]["open_questions"] = [] + + assert any("must list open_questions" in error for error in validate_matrix(_save(root, "microsoft", matrix))) + + +def test_checker_rejects_high_confidence_on_non_ga_mcp(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + matrix = _load(root, "google") + action = matrix["actions"][0] + action.update({"substrate": "mcp", "substrate_ga_status": "developer_preview", "confidence": "high"}) + + assert any("non-GA MCP substrate" in error for error in validate_matrix(_save(root, "google", matrix))) + + +def test_checker_reports_missing_matrix_file(tmp_path: Path) -> None: + missing = tmp_path / "matrices" / "missing.json" + + assert validate_matrix(missing) == [ + f"could not read capability matrix {missing}: [Errno 2] No such file or directory: '{missing}'" + ] + + +def test_checker_reports_malformed_matrix_json(tmp_path: Path) -> None: + malformed = tmp_path / "malformed.json" + malformed.write_text("{not-json", encoding="utf-8") + + errors = validate_matrix(malformed) + + assert len(errors) == 1 + assert "is not valid JSON" in errors[0] + assert str(malformed) in errors[0] + + +def test_validate_all_reports_missing_provider(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + (root / "matrices" / "slack.json").unlink() + + assert any("missing capability matrices for ['slack']" in error for error in validate_all(root / "matrices")) + + +def test_every_declared_owner_is_tracked_in_sign_off_tables() -> None: + assert validate_sign_offs() == [] + + +def test_gate_close_rejects_blank_owner_signatures() -> None: + errors = validate_sign_offs(require_complete=True) + + assert "sign-off: README.md row 'lfx owner' must complete Name, Date, and PR" in errors + assert any( + "decisions/substrate-google.md row 'lfx owner' must complete Name, Date, and PR" in error for error in errors + ) + + +def test_gate_close_accepts_completed_owner_signatures(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + for record in root.rglob("*.md"): + text = record.read_text(encoding="utf-8") + record.write_text( + text.replace("| | | |", "| Test Owner | 2026-09-01 | #14906 |"), + encoding="utf-8", + ) + + assert validate_sign_offs(root, require_complete=True) == [] + + +def test_sign_off_check_rejects_owner_missing_from_readme_and_record_tables(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + record = root / "decisions" / "substrate-google.md" + text = record.read_text(encoding="utf-8").replace( + "Owners (sign-off roles): lfx owner,", "Owners (sign-off roles): platform owner, lfx owner,", 1 + ) + record.write_text(text, encoding="utf-8") + + errors = validate_sign_offs(root) + + assert "sign-off: README.md row for 'platform owner' does not list `decisions/substrate-google.md`" in errors + assert "sign-off: decisions/substrate-google.md table lacks a row for 'platform owner'" in errors + + +def test_sign_off_check_rejects_record_dropped_from_readme_row(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + readme = root / "README.md" + # The record is also cited in the exit-criteria table above the sign-off section; only the sign-off rows change. + head, sign_off = readme.read_text(encoding="utf-8").split("## Sign-off", 1) + sign_off = sign_off.replace("`decisions/kb-oauth-connector-adoption.md`", "") + readme.write_text(head + "## Sign-off" + sign_off, encoding="utf-8") + + errors = validate_sign_offs(root) + + assert "sign-off: README.md row for 'lfx owner' does not list `decisions/kb-oauth-connector-adoption.md`" in errors + assert ( + "sign-off: README.md row for 'langflow-base owner' does not list `decisions/kb-oauth-connector-adoption.md`" + in errors + ) + + +def test_sign_off_check_rejects_undeclared_row_in_record_table(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + record = root / "decisions" / "palette-naming.md" + text = record.read_text(encoding="utf-8").replace( + "| product owner | | | |", "| product owner | | | |\n| lfx owner | | | |", 1 + ) + record.write_text(text, encoding="utf-8") + + errors = validate_sign_offs(root) + + assert "sign-off: decisions/palette-naming.md table row 'lfx owner' is not a declared owner" in errors + + +def test_sign_off_check_rejects_record_without_sign_off_table(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + record = root / "frontend-surfaces.md" + record.write_text(record.read_text(encoding="utf-8").split("## Sign-off", 1)[0], encoding="utf-8") + + errors = validate_sign_offs(root) + + assert ( + "sign-off: frontend-surfaces.md declares ['frontend owner', 'release owner'] but has no '## Sign-off' table" + in errors + ) + + +def test_sign_off_check_reports_deleted_readme(tmp_path: Path) -> None: + root = _copy_design_tree(tmp_path) + (root / "README.md").unlink() + + assert validate_sign_offs(root) == [f"sign-off: {root / 'README.md'} does not exist"] + + +def test_require_accepted_walks_every_decision_record(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + # palette-naming.md is not referenced from any matrix; gate close must still require it to be accepted. + record = root / "decisions" / "palette-naming.md" + text = re.sub(r"^Status:.*$", "Status: draft", record.read_text(encoding="utf-8"), count=1, flags=re.MULTILINE) + record.write_text(text, encoding="utf-8") + + assert validate_decision_records(root) == [] + assert validate_all(root / "matrices") == [] + errors = validate_all(root / "matrices", require_accepted=True) + assert "gate decision record 'decisions/palette-naming.md' is draft, not accepted" in errors + + +def test_require_accepted_fails_on_draft_record(tmp_path: Path) -> None: + root = _copy_design(tmp_path) + record = root / "decisions" / "substrate-google.md" + text = re.sub(r"^Status:.*$", "Status: draft", record.read_text(encoding="utf-8"), count=1, flags=re.MULTILINE) + record.write_text(text, encoding="utf-8") + + assert validate_matrix(root / "matrices" / "google.json") == [] + errors = validate_matrix(root / "matrices" / "google.json", require_accepted=True) + assert any("is draft, not accepted" in error for error in errors) + + +def test_desktop_uses_langflow_owned_public_clients() -> None: + """decisions/desktop-oauth-ownership.md: Desktop defaults to a Langflow-owned PKCE public client.""" + for provider in REQUIRED_PROVIDERS: + matrix = _load(DESIGN_ROOT, provider) + assert matrix["oauth_app_owner_by_context"]["desktop"] == "langflow", provider + assert matrix["oauth_client_type_by_context"]["desktop"] == "public", provider + for action in matrix["actions"]: + if action["decision"] != "include": + continue + contexts = action["deployment_contexts"] + if action["identity"] == "bot": + assert "desktop" not in contexts, f"{action['action_id']} offers bot scopes on Desktop" + else: + assert contexts.get("desktop") == "loopback_redirect", action["action_id"]