diff --git a/.changeset/connector-authoring-guide.md b/.changeset/connector-authoring-guide.md new file mode 100644 index 0000000000..5b684a8655 --- /dev/null +++ b/.changeset/connector-authoring-guide.md @@ -0,0 +1,12 @@ +--- +--- + +docs-only: hand-written connector authoring guide at +`content/docs/automation/connectors.mdx` (#4289) — the declarative +`connectors:` path (ADR-0097) finally has a page: the three entry shapes, +per-provider `providerConfig` contracts (`rest` / `openapi` / `mcp`), +`credentialRef`-based auth with the enterprise-tier `oauth2` absence stated +explicitly, boot/reload failure modes, and the showcase pointer. The two +connector-auth entries in `packages/spec/variant-docs.json` flip from +`exempt` to governed, so future auth variants must update the guide. +Releases nothing. diff --git a/content/docs/ai/connect-mcp.mdx b/content/docs/ai/connect-mcp.mdx index c6cf8acecb..a16e615763 100644 --- a/content/docs/ai/connect-mcp.mdx +++ b/content/docs/ai/connect-mcp.mdx @@ -204,3 +204,4 @@ skill and a guided `/objectstack:connect` command. - [Actions](/docs/ui/actions) — defining the actions you expose - [Your app as an MCP server](/docs/api#your-app-as-an-mcp-server) — the API-level view - [AI Agents](/docs/ai/agents) — building agents *inside* your app (the other direction) +- [Connectors](/docs/automation/connectors) — the outbound counterpart: *consuming* an external MCP server as a flow-dispatchable connector (`provider: 'mcp'`) diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx new file mode 100644 index 0000000000..c9148dc81a --- /dev/null +++ b/content/docs/automation/connectors.mdx @@ -0,0 +1,363 @@ +--- +title: Connectors +description: Call external systems from flows — plugin-registered connectors, and declarative provider-bound instances (rest / openapi / mcp) authored as pure metadata with reference-based credentials. +--- + +# Connectors + +> **Status:** Shipped · **Audience:** App authors (human and AI), integration +> engineers +> +> **TL;DR** — A connector packages an external system behind named **actions** +> that flows dispatch with the `connector_action` node. You get one in two +> ways: a **plugin** registers it from host code, or — since ADR-0097 — you +> **declare** it as pure metadata: a `connectors:` entry naming a `provider` +> (`rest`, `openapi`, or `mcp`) is materialized into a live connector at boot, +> no plugin code required. Credentials are **references** (`credentialRef`), +> never secrets inlined in metadata. This page is the authoring guide for that +> declarative path — what to write, how auth works, and how it fails. + +A connector is a **transport mechanism**: it knows how to reach one external +system and exposes that system as a set of dispatchable actions. The runtime +keeps a **connector registry** — `GET /api/v1/automation/connectors` lists it, +the Studio flow palette browses it, and a flow's +[`connector_action` node](/docs/automation/flows#node-types) dispatches against +it. Connectors are *not* messaging channels (ADR-0022): "send a Slack message" +is a connector action; routing, templating, and user notification preferences +live in the messaging layer. + +## The three shapes of a `connectors:` entry + +| Shape | Behavior comes from | When to use | +|:---|:---|:---| +| **Plugin-registered connector** | Host code: a plugin calls `engine.registerConnector(def, handlers)` (brand connectors like Slack, or the executors' hand-wired instance options) | The integration needs custom logic, or ships as an installable plugin | +| **Provider-bound instance** (ADR-0097) | An installed **generic executor** (`rest` / `openapi` / `mcp`) materializes the entry at boot | The upstream is an HTTP API, an OpenAPI document, or an MCP server — declare it as metadata, write no code | +| **Catalog descriptor** | Nothing — the entry is inert documentation | Cataloguing a planned or externally-managed integration ([below](#catalog-descriptors-entries-without-a-provider)) | + +The first shape is code and belongs to [plugin development](/docs/plugins). +This guide is about the other two — the entries a tenant (or an AI author) +writes into stack metadata. + +## Declaring a provider-bound instance + +A declarative entry becomes a live connector the moment it names a `provider`: + +{/* os:check */} +```typescript +import { defineConnector } from '@objectstack/spec/integration'; + +export const BillingApiConnector = defineConnector({ + name: 'billing_api', + label: 'Billing API', + type: 'api', + provider: 'rest', + providerConfig: { + baseUrl: 'https://billing.example.com', + }, + auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, +}); +``` + +Wire it into the stack alongside your other metadata: + +```ts +// objectstack.config.ts +export default defineStack({ + // ... + connectors: [BillingApiConnector], +}); +``` + +At boot, the automation service resolves each provider-bound entry: + +1. **Look up the provider factory** registered under `entry.provider`. The + three generic executors — `@objectstack/connector-rest`, + `@objectstack/connector-openapi`, `@objectstack/connector-mcp` — each + contribute one; scaffolded projects (`npm create objectstack`) ship all + three in `plugins:` by default, paired with the `automation` capability + that performs the materialization. +2. **The factory validates `providerConfig`** and the automation service + resolves `auth.credentialRef` to its secret. +3. **The result is registered** on the connector registry — indistinguishable + from a hand-written connector. Its **actions are derived from the + upstream** (the OpenAPI document's operations, the MCP server's + `tools/list`, the REST executor's generic `request`), never authored. + +Three authoring rules are enforced when the stack is validated: + +- `providerConfig` and `auth` **require** `provider` — on an entry without one + they are meaningless materialization inputs and are rejected. +- A provider-bound instance must **not** inline secrets via `authentication` + (the runtime auth shape) — see [Authentication](#authentication). +- A provider-bound instance must **not** author `actions` or `triggers` — the + provider derives actions from the upstream at boot; authoring both the + instance and its actions would let them drift apart (ADR-0097 §5). + +## Provider config contracts + + +`providerConfig` is deliberately **not** validated by the stack schema — each +provider factory validates its own config **at boot** (re-modelling an OpenAPI +document or an MCP transport inside the stack schema is exactly what ADR-0023 +rejected). A wrong or missing key is therefore *not* flagged by `os validate`; +it surfaces as a **hard boot error** naming the connector. Check the contracts +below against what you wrote. + + +### `provider: 'rest'` — a generic HTTP client + +Config: **`baseUrl`** (required, e.g. `https://api.example.com`) and +**`defaultHeaders`** (optional string→string map merged into every request; +request-level headers win). + +Materializes a single **`request`** action accepting +`{ method?, path?, headers?, query?, body? }` — the flow supplies the path and +payload per call. Use it when the upstream is "just HTTP" and you don't have a +spec document. + +### `provider: 'openapi'` — one action per operation + +Config: **`spec`** (required) and **`baseUrl`** (optional — overrides the +document's `servers[0].url`). The `spec` takes any of three forms: + +- an **inline OpenAPI 3.x document object** — no I/O at boot, the most + deterministic form; +- an **`http(s)` URL**, fetched at materialization; +- a **package-relative file path** like `'./specs/billing-openapi.json'`, + resolved against the directory containing `objectstack.config.ts` and + **confined to it** — absolute and `..`-escaping paths are rejected. + +{/* os:check */} +```typescript +import { defineConnector } from '@objectstack/spec/integration'; + +export const CrmApiConnector = defineConnector({ + name: 'crm_api', + label: 'CRM API', + type: 'api', + provider: 'openapi', + providerConfig: { + spec: './specs/crm-openapi.json', + }, + auth: { type: 'api-key', credentialRef: 'CRM_API_KEY', headerName: 'X-API-Key' }, +}); +``` + +Each operation becomes an action keyed by its `operationId` (or a +`method_path` slug when the document omits one) — so `getInvoice` in the +document is `actionId: 'getInvoice'` in your flow. + +### `provider: 'mcp'` — one action per tool + +Config: **`transport`** (required) and **`include`** (optional tool-name +allowlist — only the listed tools become actions): + +- `{ kind: 'http', url, headers? }` — a remote MCP server over streamable + HTTP. Resolved `auth` is folded into the request headers. +- `{ kind: 'stdio', command, args?, env? }` — a **local child process**, + policy-gated (below). Credentials for a stdio server ride `transport.env`, + not `auth`. + +{/* os:check */} +```typescript +import { defineConnector } from '@objectstack/spec/integration'; + +export const SearchToolsConnector = defineConnector({ + name: 'search_tools', + label: 'Search Tools (MCP)', + type: 'api', + provider: 'mcp', + providerConfig: { + transport: { kind: 'http', url: 'https://mcp.example.com/mcp' }, + include: ['web_search', 'fetch_page'], + }, + auth: { type: 'bearer', credentialRef: 'SEARCH_MCP_TOKEN' }, +}); +``` + +At materialization the provider connects, calls `tools/list`, and maps each +tool to an action — the MCP server's tool names are your `actionId`s. + + +**Declarative stdio transports are denied by default.** Materializing one +spawns a local process *from metadata* — which a runtime Studio publish can +introduce — so anyone who can publish metadata could otherwise execute +commands on the server. The host opts in per command: + +```ts +new ConnectorMcpPlugin({ declarativeStdio: ['node'] }) // exact-match allowlist +``` + +The boot error you hit without the opt-in spells out this exact fix. The +allowlist is a deliberately coarse boundary — allowlisting a launcher like +`npx` effectively trusts anything it can run, so list the specific server +binaries you trust. Hand-wired MCP connectors configured in host code are not +subject to this policy (their command *is* host code), and `http` transports +need no opt-in. + + +## Authentication + +Connector auth answers "how do we authenticate **to the external system**" — +it is unrelated to how users authenticate to ObjectStack. There are two shapes, +and which one you write depends on which of the two connector worlds you are +in: + +### Declarative instances: references, never secrets + +Stack metadata is authored, versioned, and shipped — a raw token must never +live in it (ADR-0097 §3). The `auth` field on a provider-bound instance +therefore has **no field that can hold a secret**. Its secret-bearing variants +carry a **`credentialRef`** instead, resolved at materialization: + +```ts +auth: { type: 'none' } // public upstream +auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' } +auth: { type: 'api-key', credentialRef: 'CRM_API_KEY', + headerName: 'X-API-Key' } // or paramName: 'api_key' +auth: { type: 'basic', username: 'svc_objectstack', // username is not a secret + credentialRef: 'ERP_SVC_PASSWORD' } // the password is +``` + +Variant notes: + +- **`api-key`** — the key travels in a header (`headerName`, default + `X-API-Key`) or a query parameter (`paramName`); set one or the other. +- **`basic`** — the `username` stays in metadata (it is not a secret); only + the password resolves through `credentialRef`. + +**What `credentialRef` resolves through.** In the open tier the ref is the +name of an **environment variable** (`BILLING_API_TOKEN` above means +`process.env.BILLING_API_TOKEN` on the server). A ref that resolves to nothing +is a **hard boot error** — an app must not silently run with a dead connector. +The enterprise tier swaps in a vault/KMS-backed resolver +(`AutomationServicePluginOptions.credentialResolver`) without changing what +you author: the entry still just names a ref. + +Inlining a secret is rejected when the stack is validated. Writing the runtime +shape on a provider-bound instance — + +```ts +// ✗ rejected at authoring/publish: +// "must not inline secrets via `authentication`; reference credentials +// with `auth: { type, credentialRef }` instead (ADR-0097 §3)." +authentication: { type: 'bearer', token: 'sk_live_9f…' }, + +// ✓ a reference, resolved at boot: +auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, +``` + +— fails validation, and `auth: { type: 'bearer', token: … }` is not even a +legal shape (the declarative union has no `token` field to put a secret in). + +### Where is `oauth2`? + +Deliberately absent from declarative instances — **not** an oversight to work +around. The runtime auth shape has five variants (`none`, `bearer`, +`api-key`, `basic`, `oauth2`); the declarative shape stops at four. Static +credentials resolved from env/config are the open tier; the OAuth2 +authorization-code/refresh **lifecycle** — token acquisition, rotation, +per-tenant connections — is the enterprise tier (ADR-0015). If your upstream +needs OAuth2, use an enterprise credential resolver or register the connector +from a plugin that manages its own tokens; don't try to emulate it by stuffing +a short-lived access token behind a `bearer` ref. + +### The runtime shape: `authentication` + +The `authentication` field is the **runtime** auth config — it carries the +resolved secret *inline* (`{ type: 'bearer', token }`), because it is supplied +by **host code** (a plugin calling `engine.registerConnector`, or the +executors' hand-wired instance options reading `process.env`). That is a +different trust anchor than metadata: code review guards what enters it, and +it is never serialized into a versioned artifact. On declarative entries, +leave `authentication` alone — provider-bound instances must use `auth`, and +catalog descriptors documenting a planned integration must still never carry a +live secret value. + +## Dispatching from a flow + +A materialized instance is dispatched exactly like any other connector — by +`name` and action key: + +```ts +{ + id: 'ping', + type: 'connector_action', + label: 'GET /api/v1/health', + connectorConfig: { + connectorId: 'billing_api', // the connector's `name` + actionId: 'request', // rest: 'request' · openapi: operationId · mcp: tool name + input: { method: 'GET', path: '/api/v1/health' }, + }, +}, +``` + +See [Flows](/docs/automation/flows) for the node reference and how outputs +flow into downstream nodes. + +## Failure modes at a glance + +Boot distinguishes **configuration faults** (your entry is wrong — fail loud, +fix the metadata) from **operational faults** (the upstream is down — degrade, +retry): + +| You wrote / it happened | What you get | +|:---|:---| +| `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` | +| Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory | +| `credentialRef` resolves to nothing | **Hard boot error** — set the env var (open tier) or fix the vault ref | +| Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | +| stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried | +| MCP server unreachable / `tools/list` fails; remote spec URL unreachable or transiently failing (408/429/5xx) | **Not fatal**: the instance registers **degraded** (visible on `GET /api/v1/automation/connectors` with the reason), dispatches fail with that reason, and the platform retries on a 5s→5min backoff — recovery is automatic | +| Wrong spec URL (other 4xx) or unparseable document | Configuration fault — **hard boot error** | +| A bad entry arrives via **runtime reload** (Studio publish / dev reload) | Logged and **skipped**, never crashing the live server; a changed instance's old connector keeps serving until its replacement materializes | + +## Catalog descriptors: entries without a `provider` + +An entry with no `provider` never reaches the registry — it is an inert +**descriptor** for discovery, documentation, or marketplace listing. It *may* +author `actions` (as documentation of a planned surface, including I/O JSON +Schemas). Because a declared-with-actions connector that nothing registers is +usually a mistake, boot **warns** about it — mark a deliberate descriptor with +`enabled: false` to state the intent and silence the audit: + +{/* os:check */} +```typescript +import { defineConnector } from '@objectstack/spec/integration'; + +export const ErpCatalogConnector = defineConnector({ + name: 'erp_catalog', + label: 'ERP Integration (planned)', + type: 'saas', + description: 'Planned ERP integration — catalogued, not yet dispatchable.', + actions: [ + { key: 'get_invoice', label: 'Get Invoice' }, + ], + enabled: false, // deliberate catalog-only descriptor: suppresses the boot audit warning +}); +``` + +A descriptor sharing a live connector's name stays legal — it is catalog +metadata *about* that connector. (A provider-bound instance sharing one is the +boot error from the table above.) + +## A complete, runnable example + +The showcase app exercises every shape on a real boot — +[`examples/app-showcase/src/system/connectors/index.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/system/connectors/index.ts) +declares a `rest` instance pointing at the server's own health endpoint, an +`openapi` instance whose spec is a package-relative file, an `mcp` instance +spawning the in-repo stdio fixture under a `declarativeStdio` allowlist, and a +catalog descriptor — with flows dispatching the `rest` and `mcp` instances +end-to-end. Start from it rather than assembling the shapes from scratch: + +```bash +pnpm dev +``` + +Related pages: [Flows](/docs/automation/flows) · +[Webhook Delivery](/docs/automation/webhooks) (the outbound-notification +counterpart) · [Connect an MCP Client](/docs/ai/connect-mcp) (the **opposite +direction**: exposing *this* platform to an external MCP client) · +[Connector schema reference](/docs/references/integration/connector) +(generated from the schema). diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index ead8dbe7e2..c259753389 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -119,7 +119,7 @@ Each node performs a specific action in the flow. | `map` | Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039) | | `connector_action` | Execute an external connector action | -`connector_action` dispatches against the runtime **connector registry**, which connector plugins populate. The three **generic executors** — `rest`, `openapi`, and `mcp` — double as provider factories: with them in your app's `plugins:`, a declarative `connectors:` entry that names a `provider` is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Scaffolded projects (`npm create objectstack`) ship all three executors by default — paired with the `automation` capability that performs the materialization — and the showcase wires them too. Brand connectors (Slack, …) are installed separately. One security note: a declarative `mcp` instance using a **stdio** transport spawns a local process from metadata and is therefore denied by default — the host opts in with `new ConnectorMcpPlugin({ declarativeStdio: [''] })`; `http` transports need no opt-in. +`connector_action` dispatches against the runtime **connector registry**, which connector plugins populate. The three **generic executors** — `rest`, `openapi`, and `mcp` — double as provider factories: with them in your app's `plugins:`, a declarative `connectors:` entry that names a `provider` is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Scaffolded projects (`npm create objectstack`) ship all three executors by default — paired with the `automation` capability that performs the materialization — and the showcase wires them too. Brand connectors (Slack, …) are installed separately. One security note: a declarative `mcp` instance using a **stdio** transport spawns a local process from metadata and is therefore denied by default — the host opts in with `new ConnectorMcpPlugin({ declarativeStdio: [''] })`; `http` transports need no opt-in. [Connectors](/docs/automation/connectors) is the full authoring guide — provider config contracts, `credentialRef` auth, and failure modes. ### Node Structure diff --git a/content/docs/automation/index.mdx b/content/docs/automation/index.mdx index 25e64dd4a4..b67ed9df86 100644 --- a/content/docs/automation/index.mdx +++ b/content/docs/automation/index.mdx @@ -1,6 +1,6 @@ --- title: Automation -description: Hooks, flows, workflows, approvals, scheduled jobs, and durable webhooks — the process engine that reacts to your data. +description: Hooks, flows, workflows, approvals, scheduled jobs, durable webhooks, and connectors — the process engine that reacts to your data. --- # Automation @@ -33,6 +33,7 @@ export const OpportunityStageHook: Hook = { - **Workflows** model a record's lifecycle as a **finite state machine**: states, transitions, and guards ([Workflows](/docs/automation/workflows)). - **Approvals** are flow nodes with approver resolution, approve/reject decisions, and escalation ([Approvals](/docs/automation/approvals)). - **Webhooks** deliver events to external systems through a **durable outbox** — exponential/linear/fixed retry with dead-lettering, HMAC signing, and an admin redeliver endpoint ([Webhook Delivery](/docs/automation/webhooks)). +- **Connectors** package external systems behind named actions that flows dispatch — registered by plugins, or **declared as pure metadata** (`provider: 'rest' | 'openapi' | 'mcp'`) and materialized at boot, with reference-based credentials ([Connectors](/docs/automation/connectors)). - **Scheduled jobs** run on `setInterval` or cron via the job service, alongside `schedule`-type flows. Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks for *code-level* reactions, and webhooks to *notify the outside world*. @@ -46,6 +47,7 @@ Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks + ## Related diff --git a/content/docs/automation/meta.json b/content/docs/automation/meta.json index 011acd27dc..c50d547968 100644 --- a/content/docs/automation/meta.json +++ b/content/docs/automation/meta.json @@ -7,6 +7,7 @@ "flows", "workflows", "approvals", - "webhooks" + "webhooks", + "connectors" ] } diff --git a/content/docs/capabilities/integrations.mdx b/content/docs/capabilities/integrations.mdx index fe404e021e..c927c8db12 100644 --- a/content/docs/capabilities/integrations.mdx +++ b/content/docs/capabilities/integrations.mdx @@ -12,7 +12,7 @@ A business system earns its keep by fitting into the systems — and habits — - **Generated REST APIs** — every object gets standard endpoints automatically, so your website, mini-program, or ERP reads and writes through the same permission gate as people. - **Webhooks out** — data changes notify external systems (*deal won → billing system opens an invoice*). - **Triggers in** — external systems start platform [flows](/docs/capabilities/automation). -- **Connectors** — ready-made Slack and generic REST connectors drop into flows. +- **Connectors** — ready-made Slack and generic REST connectors drop into flows; an HTTP API, OpenAPI document, or MCP server can be [declared as metadata](/docs/automation/connectors) and goes live at boot, no plugin code. - **Federated external databases** — point at an existing database and query it in place: no migration, visible in views, usable in flows. - **Marketplace templates** — install complete apps (HotCRM among them) with one click: objects, views, flows, dashboards, and seed data included, then customize in Studio. - **Deploy anywhere** — cloud or fully self-hosted; your data and your app definition stay yours. @@ -27,4 +27,4 @@ Interface text — labels, options, messages — is translatable per language, a > **In HotCRM**: the interface ships in four languages (English, Chinese, Japanese, Spanish); the Slack connector and web-to-lead intake demonstrate both directions of integration. -**For developers**: [API & SDK](/docs/api), [connectors and webhooks](/docs/automation), and [translations](/docs/ui/translations). +**For developers**: [API & SDK](/docs/api), [connectors](/docs/automation/connectors), [webhooks](/docs/automation/webhooks), and [translations](/docs/ui/translations). diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 9ba4a13dde..e2495c8c12 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -28,7 +28,8 @@ " - the remaining exemptions now state the gap plainly instead of pointing at the", " generated page as if it settled the question. Connector authentication is the one", " worth acting on: it is tenant-authored (ADR-0097 declarative `connectors:`) and the", - " repo has no hand-written connector guide at all." + " repo has no hand-written connector guide at all. (Since closed: #4289 wrote", + " content/docs/automation/connectors.mdx and both connector-auth entries are governed.)" ], "entries": [ { @@ -80,14 +81,14 @@ { "key": "type:api-key|basic|bearer|none|oauth2", "label": "connector authentication", - "exempt": "generated-reference-only", - "reason": "DOCUMENTATION GAP, not a non-authorable surface — the strongest candidate among the exemptions for a real guide. Connector auth IS tenant-authored (ADR-0097 materializes a declarative `connectors:` entry naming a `provider` into a live connector at boot), but the repo has no hand-written connector page at all: the only prose is one paragraph inside automation/flows.mdx about the `connector_action` node. Until a guide exists there is nothing for this entry to bind to; when one is written, bind it and delete this exemption." + "docs": ["content/docs/automation/connectors.mdx"], + "note": "The runtime auth shape (ConnectorAuthConfigSchema) — five variants including the enterprise-tier `oauth2`. Was exempt generated-reference-only while no hand-written connector page existed; #4289 wrote the guide and bound it, per the old exemption's own instruction." }, { "key": "type:api-key|basic|bearer|none", "label": "connector auth (environment-artifact projection)", - "exempt": "generated-reference-only", - "reason": "The declarative connector-instance auth shape (ADR-0097), reached via EnvironmentArtifactSchema.metadata.connectors[].auth. It carries a `credentialRef` rather than an inline secret, and omits `oauth2` because the authorization-code/refresh lifecycle is the enterprise tier (ADR-0015) — not because the artifact narrows it. Inherits the gap above; a connector guide would govern both." + "docs": ["content/docs/automation/connectors.mdx"], + "note": "The declarative connector-instance auth shape (ADR-0097), reached via EnvironmentArtifactSchema.metadata.connectors[].auth: `credentialRef` references only, and `oauth2` deliberately absent (enterprise tier, ADR-0015) — the guide states that absence explicitly so authors don't read it as an omission." }, { "key": "strategy:isolated_db|isolated_schema|shared_schema",