diff --git a/.changeset/declarative-connectors-2612-descriptor-contract.md b/.changeset/declarative-connectors-2612-descriptor-contract.md new file mode 100644 index 0000000000..01ad8d4b76 --- /dev/null +++ b/.changeset/declarative-connectors-2612-descriptor-contract.md @@ -0,0 +1,32 @@ +--- +'@objectstack/service-automation': minor +'@objectstack/spec': patch +--- + +feat(automation): descriptor-only contract + boot audit for declarative `connectors:` (#2612) + +Declarative `connectors:` stack entries never reach the automation engine's +connector registry — only plugins populate it via +`engine.registerConnector(def, handlers)` (ADR-0018 §Addendum) — so a declared +connector with actions and no plugin behind it *looked* dispatchable but was +silently inert. + +The contract is now explicit and audited: + +- **Boot audit (service-automation).** At `kernel:ready` (and again on + `metadata:reloaded`), declared connectors with `actions` but no same-name + runtime registration log a loud warning naming each inert entry and + pointing at the fix (install the matching connector plugin, or mark a + deliberate catalog entry). Nothing is registered on your behalf — the + warning surfaces the gap `connector_action` would otherwise hit at + dispatch time. +- **`enabled: false` = deliberate catalog descriptor (spec).** Setting it on + a declarative entry documents "descriptor-only on purpose" and silences the + audit. Schema docs on `stack.zod.ts` (`connectors:`) and + `integration/connector.zod.ts` now state the descriptor-vs-registered + contract explicitly (including for AI stack authoring via `.describe()`). + +Declarative provider-bound connector *instances* — entries a generic executor +(connector-openapi / connector-mcp) materializes into live connectors at boot, +upgrading this warning to a hard error — are specified in ADR-0096 and tracked +in #2977. diff --git a/docs/adr/0096-declarative-connector-instances.md b/docs/adr/0096-declarative-connector-instances.md new file mode 100644 index 0000000000..9cf38743ca --- /dev/null +++ b/docs/adr/0096-declarative-connector-instances.md @@ -0,0 +1,101 @@ +# ADR-0096: Declarative Connector Instances — Provider-Bound `connectors:` Entries Materialized by Generic Executors + +**Status**: Proposed (2026-07-15) +**Deciders**: ObjectStack Protocol Architects +**Builds on**: [ADR-0015](./0015-external-datasource-federation.md) (open mechanism / enterprise lifecycle split), [ADR-0018](./0018-unified-node-action-registry.md) (`connector_action` baseline dispatch + `engine.registerConnector()`), [ADR-0022](./0022-connectors-vs-messaging-channels.md) (Connector = transport/integration mechanism), [ADR-0023](./0023-openapi-to-connector-generator.md) (OpenAPI → Connector generator), [ADR-0024](./0024-mcp-connectors.md) (MCP servers as connectors) +**Tracking**: framework#2977 (supersedes the interim descriptor-only contract from framework#2612) +**Consumers**: `@objectstack/spec` (`stack.zod.ts`, `integration/connector.zod.ts`), `@objectstack/service-automation` (boot audit → provider binding), `@objectstack/connector-openapi`, `@objectstack/connector-mcp`, `@objectstack/connector-rest`, the Studio flow palette, AI authoring (stack generation) + +--- + +## TL;DR + +Declarative `connectors:` stack entries are **inert**: they register as metadata but never reach the automation engine's connector registry, which only plugins populate via `engine.registerConnector(def, handlers)` (#2612). The interim fix documented the collection as **descriptor-only** (catalog entries; boot audit warns on declared-with-actions-but-unregistered). This ADR specifies the upgrade: a declarative entry may name a **`provider`** — an installed generic executor (`openapi`, `mcp`, `rest`) — which **materializes** the entry into a live, dispatchable connector at boot. Declared-with-provider but no matching executor installed ⇒ **hard boot error** (upgrade of the audit warning). Credentials are **references** (`credentialRef`), never inline secrets. + +Why this matters for the platform's mission: ObjectStack builds enterprise core business systems **with AI, out of metadata**. Enterprise core systems are integration-heavy by definition, and AI's native output is metadata, not plugin code. Every comparable platform ships this as table stakes — Salesforce External Services (OpenAPI spec → invocable Flow actions, zero code), Power Platform custom connectors (the connector *is* an OpenAPI document; per-environment "connections" carry the credentials), ServiceNow IntegrationHub spokes. A schema collection the AI can author but the runtime ignores is the worst failure mode a metadata platform can ship: plausible, validated, dead. + +The runtime substrate already exists — ADR-0023/0024 deliver generic executors that turn declarative inputs (an OpenAPI document, an MCP endpoint) into `{ def, handlers }` bundles. This ADR adds only the **last mile**: stack metadata → executor factory → `registerConnector`. + +--- + +## Context + +### The two connector worlds (#2612) + +1. **Runtime registry** (live): `engine.registerConnector(def, handlers)` requires a handler per action and backs `GET /connectors` + the `connector_action` node. Populated exclusively by plugins. +2. **Declarative `connectors:`** (inert): registered as metadata kind 'connector'; `ConnectorActionSchema` deliberately carries **no execution binding** (no method/path — ADR-0023 rejected re-inventing OpenAPI inside the stack schema), so a declared action *cannot* be interpreted into behavior. + +The interim state (shipped with #2612's resolution): the contract is documented as descriptor-only, `enabled: false` marks deliberate catalog entries, and the automation service warns at boot about declared-with-actions entries lacking a same-name runtime registration. + +### Why naive bridging was rejected + +Matching declared entries to already-installed plugin connectors **by name** adds nothing (the plugin registers itself) and creates a two-sources-of-truth hazard: the declared def and the plugin def of the same connector can drift. Any real bridge must make the declarative entry the **configuration** and the executor the **behavior** — type/instance separation, exactly the datasource pattern (declared in stack, adapter in code). + +--- + +## Decision + +### 1. `provider` on the stack-level connector entry + +A declarative connector entry MAY carry a `provider` plus provider-specific config: + +```ts +connectors: [{ + name: 'billing', + label: 'Billing API', + type: 'api', + provider: 'openapi', // ← which installed executor materializes this + providerConfig: { // provider-specific; validated by the provider + spec: './specs/billing-openapi.json', // openapi: document ref + baseUrl: 'https://billing.example.com', + }, + auth: { type: 'bearer', credentialRef: 'billing_api_token' }, // reference, never a secret +}] +``` + +- **No `provider`** → the entry is a catalog descriptor, exactly the #2612 interim contract (audit warning applies unless `enabled: false`). +- **`provider` present** → the entry is an **instance declaration** and MUST be materialized at boot. + +### 2. Providers are factories contributed by connector plugins + +A connector plugin (e.g. `@objectstack/connector-openapi`) registers a **provider factory** under its provider key at `init()`. At `kernel:ready`, the automation service resolves each instance declaration: + +1. Look up the factory for `entry.provider`. +2. Factory validates `providerConfig`, resolves `credentialRef` via the secrets layer, and returns the same `{ def, handlers }` bundle the generator APIs already produce (ADR-0023 §1). +3. The service calls `engine.registerConnector(def, handlers)` — the registry and `connector_action` see a finished connector, indistinguishable from a hand-written one. + +**Declared `provider` with no installed factory ⇒ boot fails loudly** (validation error naming the entry, the provider, and the plugin that supplies it). This upgrades the #2612 audit from warning to error, but **only** for provider-bound entries — plain descriptors keep the warning-or-`enabled:false` contract. + +### 3. Credentials are references + +`credentialRef` resolves through the secrets service at materialization time. Inline secrets in stack metadata are rejected at authoring/publish (lint + schema). Per the ADR-0015 line: static credentials resolved from env/config are **open**; managed vaulting, OAuth2 refresh, and per-tenant connection lifecycle are **enterprise**. + +### 4. Two-sources-of-truth rule + +If a provider-bound instance and a plugin-registered connector share a `name`, boot fails with a naming-conflict error — there is no precedence, because silent precedence is how drift hides. (Plain descriptors sharing a live connector's name stay legal: the descriptor is catalog metadata *about* the live connector.) + +### 5. Non-goals + +- **No execution bindings inside `ConnectorActionSchema`.** Actions on an instance declaration are *derived by the provider* (from the OpenAPI document / MCP `tools/list`), not authored. Authoring both the instance and its actions would reintroduce drift. +- **L3 aspirational surfaces stay out of scope**: `syncConfig`, `fieldMappings`, `webhooks`, `triggers` on `ConnectorSchema` remain unimplemented by this ADR. +- **No messaging semantics** — the ADR-0022 layering stands; this is transport only. + +### 6. Acceptance + +- An AI-generated app declares a connector instance (e.g. `provider: 'mcp'` pointing at an MCP server) as pure metadata; a flow `connector_action` dispatches one of its actions end-to-end. +- The showcase demonstrates the declarative path live (upgrading the catalog-descriptor demo shipped with #2612) and `GET /connectors` lists the materialized instance. +- Boot fails loudly for: unknown provider, invalid providerConfig, unresolvable credentialRef, name conflict with a plugin-registered connector. + +--- + +## Consequences + +**Positive** +- The `connectors:` collection stops being the platform's one dead metadata surface; AI can wire integrations without leaving metadata. +- Zero new runtime abstraction: providers reuse the ADR-0023/0024 generator/adapter APIs verbatim. +- The industry-standard definition/credential split (Salesforce Named Credentials, Power Platform connections) lands with the schema, not as a retrofit. + +**Negative / costs** +- Schema evolution on a shipped collection (`provider`, `providerConfig`, `credentialRef`) — additive, so no migration, but the descriptor-only docs shipped with #2612 must be revised when this lands. +- The secrets-layer dependency makes credential resolution a boot-path concern; environments without a secrets service need a clear degraded story (env-var fallback, open tier). +- Provider factories add a registration surface to connector plugins (small; mirrors how they already self-register). diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index fb12a06c8b..a0b4f34864 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -30,6 +30,7 @@ import { allJobs } from './src/automation/jobs/index.js'; import { allEmails } from './src/system/emails/index.js'; import { allBooks } from './src/system/books/index.js'; import { allApis } from './src/system/apis/index.js'; +import { allConnectors } from './src/system/connectors/index.js'; import { allPositions, allPermissionSets, @@ -189,6 +190,10 @@ export default defineStack({ // Declarative REST endpoints (object_operation + flow) — the metadata // counterpart of the code-mounted recalc endpoint (see src/system/apis/). apis: allApis, + // Declarative connector CATALOG DESCRIPTORS (#2612) — metadata-only, never + // runtime-dispatchable; the live connectors are the plugins above. See + // src/system/connectors/ for the full contract. + connectors: allConnectors, hooks: allHooks, webhooks: allWebhooks, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index b5f89ea99c..5968305e64 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -176,10 +176,10 @@ export const STACK_COLLECTION_COVERAGE: Record = { 'Declarative ApiEndpoint metadata (object_operation + flow targets), executed by the runtime dispatcher (handleApiEndpoint). Complements the code-mounted endpoint in src/system/server/ (router kind stays waived: code-only).', }, connectors: { - status: 'waived', - reason: - 'Declarative connectors: entries never reach the automation connector registry (plugin registerConnector only). Live connectors are demonstrated the delivered way: ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts.', - issue: 'https://github.com/objectstack-ai/framework/issues/2612', + status: 'demonstrated', + files: ['src/system/connectors/index.ts'], + notes: + 'Demonstrated per the descriptor-only contract (#2612): declarative connectors: entries are catalog descriptors (registered as metadata, never runtime-dispatchable; enabled:false marks the deliberate catalog entry and silences the boot audit). Live connectors are demonstrated the delivered way — ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts plugins:, exercised by the connector flows. Declarative provider-bound instances (which would make this collection dispatchable) are tracked in #2977 / ADR-0096.', }, }; diff --git a/examples/app-showcase/src/system/connectors/index.ts b/examples/app-showcase/src/system/connectors/index.ts new file mode 100644 index 0000000000..4c4d330b62 --- /dev/null +++ b/examples/app-showcase/src/system/connectors/index.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConnector, type Connector } from '@objectstack/spec/integration'; + +/** + * Declarative `connectors:` — catalog descriptors (#2612). + * + * A stack's `connectors:` collection is **descriptor-only**: entries register + * as metadata (kind 'connector') for discovery, documentation, and future + * marketplace listing, but they never reach the automation engine's connector + * registry — `connector_action` cannot dispatch them. Live connectors are the + * `plugins:` entries in objectstack.config.ts (ConnectorRestPlugin / + * ConnectorSlackPlugin), which call `engine.registerConnector(def, handlers)` + * (ADR-0018 §Addendum) and are exercised by the connector flows in + * src/automation/flows/. + * + * `enabled: false` marks the entry as a deliberate catalog-only descriptor — + * without it, the automation service's boot audit (rightly) warns that a + * declared connector with actions has no runtime registration. + * + * Declarative provider-bound connector *instances* — entries a generic + * executor (connector-openapi / connector-mcp) materializes into dispatchable + * connectors at boot — are the planned upgrade of this collection, tracked in + * https://github.com/objectstack-ai/framework/issues/2977 (ADR-0096). + */ +export const ErpCatalogConnector = defineConnector({ + name: 'showcase_erp_catalog', + label: 'ERP Integration (Catalog Descriptor)', + type: 'saas', + description: + 'Catalog-only descriptor documenting a planned ERP integration: what it is, how it authenticates, ' + + 'and which actions it will expose. Not dispatchable — see the connector plugins in ' + + 'objectstack.config.ts for the live registry entries this collection does NOT feed (#2612).', + authentication: { type: 'api-key', key: 'SET_AT_INSTALL_TIME', headerName: 'X-API-Key' }, + // Descriptor-level action catalog: key + label + I/O JSON Schemas. Note the + // deliberate absence of any execution binding (HTTP method/path) — that is + // what keeps descriptors inert today and what ADR-0096's provider binding + // supplies declaratively. + actions: [ + { + key: 'get_invoice', + label: 'Get Invoice', + description: 'Fetch a single invoice from the ERP by its number.', + inputSchema: { + type: 'object', + properties: { invoiceNumber: { type: 'string' } }, + required: ['invoiceNumber'], + }, + outputSchema: { + type: 'object', + properties: { + invoiceNumber: { type: 'string' }, + status: { type: 'string' }, + totalAmount: { type: 'number' }, + }, + }, + }, + { + key: 'post_journal_entry', + label: 'Post Journal Entry', + description: 'Write a journal entry into the ERP general ledger.', + inputSchema: { + type: 'object', + properties: { + account: { type: 'string' }, + amount: { type: 'number' }, + memo: { type: 'string' }, + }, + required: ['account', 'amount'], + }, + }, + ], + // Deliberate catalog-only descriptor: suppresses the boot inert-connector + // audit warning (#2612). + enabled: false, +}); + +export const allConnectors: Connector[] = [ErpCatalogConnector]; diff --git a/packages/services/service-automation/src/connector-descriptor-audit.test.ts b/packages/services/service-automation/src/connector-descriptor-audit.test.ts new file mode 100644 index 0000000000..3a0cc008c2 --- /dev/null +++ b/packages/services/service-automation/src/connector-descriptor-audit.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Descriptor-only contract audit (#2612): declarative `connectors:` stack +// entries are catalog descriptors — registerApp stores them as metadata, but +// the engine's connector registry is populated only by plugins calling +// `engine.registerConnector(def, handlers)` (ADR-0018 §Addendum). A declared +// connector with actions and no plugin behind it LOOKS dispatchable but is +// inert; the audit at kernel:ready surfaces exactly those entries as a loud +// warning instead of letting `connector_action` fail mysteriously at runtime. +// Provider-bound declarative instances (which upgrade this warning to an +// error) are tracked in #2977 / ADR-0096. + +import { describe, it, expect, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { AutomationServicePlugin, findInertDeclaredConnectors } from './plugin.js'; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/** A declarative connector entry, the raw shape registerApp registers. */ +function declaredConnector(name: string, opts: { actions?: number; enabled?: boolean } = {}) { + const { actions = 1, enabled } = opts; + return { + name, + label: name, + type: 'api', + authentication: { type: 'none' }, + ...(enabled === undefined ? {} : { enabled }), + actions: Array.from({ length: actions }, (_, i) => ({ + key: `action_${i}`, + label: `Action ${i}`, + })), + }; +} + +/** + * Boot a kernel with the automation plugin, a fake objectql registry serving + * the declared connector metadata, and (optionally) a live plugin-registered + * connector. Returns the shared kernel logger's warn spy. + */ +async function bootWithConnectors( + declared: unknown[], + opts: { registerLive?: string[] } = {}, +) { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + let warnSpy!: ReturnType; + const harness = { + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: [] as string[], + async init(ctx: any) { + // Kernel contexts share one logger instance (KernelBase.createContext), + // so spying here observes the automation plugin's warn calls too. + warnSpy = vi.spyOn(ctx.logger, 'warn'); + ctx.registerService('objectql', { + registry: { + listItems: (type: string) => (type === 'connector' ? declared : []), + }, + }); + for (const name of opts.registerLive ?? []) { + ctx.getService('automation').registerConnector( + { + name, + label: name, + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'action_0', label: 'Action 0' }], + }, + { action_0: async () => ({ ok: true }) }, + ); + } + }, + async start() {}, + }; + kernel.use(harness as never); + await kernel.bootstrap(); + await flush(); + return { kernel, warnSpy }; +} + +function auditWarnings(warnSpy: ReturnType): string[] { + return warnSpy.mock.calls + .map((c) => String(c[0])) + .filter((m) => m.includes('declarative connector')); +} + +describe('findInertDeclaredConnectors (pure contract)', () => { + it('flags declared connectors with actions and no runtime registration', () => { + const inert = findInertDeclaredConnectors( + [declaredConnector('crm_billing'), declaredConnector('crm_erp')], + new Set(['rest']), + ); + expect(inert).toEqual(['crm_billing', 'crm_erp']); + }); + + it('does not flag connectors that ARE runtime-registered under the same name', () => { + expect( + findInertDeclaredConnectors([declaredConnector('rest')], new Set(['rest'])), + ).toEqual([]); + }); + + it('does not flag action-less catalog descriptors', () => { + expect( + findInertDeclaredConnectors([declaredConnector('catalog_only', { actions: 0 })], new Set()), + ).toEqual([]); + }); + + it('does not flag entries explicitly opted out with enabled: false', () => { + expect( + findInertDeclaredConnectors( + [declaredConnector('deliberate_descriptor', { enabled: false })], + new Set(), + ), + ).toEqual([]); + }); + + it('tolerates malformed entries (no name, non-object) without throwing', () => { + expect( + findInertDeclaredConnectors( + [null, 42, {}, { actions: [{ key: 'a' }] }], + new Set(), + ), + ).toEqual([]); + }); +}); + +describe('kernel:ready audit (wiring)', () => { + it('warns once at boot for an inert declared connector, naming it', async () => { + const { kernel, warnSpy } = await bootWithConnectors([declaredConnector('crm_billing')]); + const warnings = auditWarnings(warnSpy); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('crm_billing'); + expect(warnings[0]).toContain('#2612'); + await kernel.shutdown(); + }); + + it('stays silent when the declared connector is plugin-registered', async () => { + const { kernel, warnSpy } = await bootWithConnectors([declaredConnector('rest')], { + registerLive: ['rest'], + }); + expect(auditWarnings(warnSpy)).toHaveLength(0); + await kernel.shutdown(); + }); + + it('stays silent for a deliberate catalog-only entry (enabled: false)', async () => { + const { kernel, warnSpy } = await bootWithConnectors([ + declaredConnector('catalog_entry', { enabled: false }), + ]); + expect(auditWarnings(warnSpy)).toHaveLength(0); + await kernel.shutdown(); + }); + + it('stays silent when nothing is declared', async () => { + const { kernel, warnSpy } = await bootWithConnectors([]); + expect(auditWarnings(warnSpy)).toHaveLength(0); + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 95ebd34ae5..f324988af8 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -51,6 +51,51 @@ export interface AutomationServicePluginOptions { runHistoryMaxPerFlow?: number; } +/** + * The shape of a declarative `connectors:` stack entry as it sits in the + * ObjectQL metadata registry (registered by `registerApp` under kind + * 'connector'). Raw authored values — Zod defaults (e.g. `enabled: true`) + * may not have been applied, so `enabled` is only trusted when explicitly + * `false`. + */ +interface DeclaredConnectorItem { + name?: string; + enabled?: boolean; + actions?: unknown[]; +} + +/** + * Descriptor-only contract audit (#2612): declarative `connectors:` stack + * entries are catalog descriptors — they are registered as metadata but never + * reach the engine's connector registry, which is populated exclusively by + * plugins calling `engine.registerConnector(def, handlers)` (ADR-0018 + * §Addendum). A declared connector that also declares `actions` looks + * dispatchable but is not — `connector_action` will fail on it at runtime. + * + * Returns the names of declared connectors that (a) declare at least one + * action, (b) have no runtime registration under the same name, and (c) are + * not explicitly opted out with `enabled: false` (the marker for a deliberate + * catalog-only entry). Provider-bound declarative instances — which will turn + * this warning into a hard error for entries that expect materialization — + * are tracked in #2977 (ADR-0096). + */ +export function findInertDeclaredConnectors( + declared: unknown[], + liveConnectorNames: ReadonlySet, +): string[] { + return declared + .map((c) => c as DeclaredConnectorItem) + .filter( + (c) => + typeof c?.name === 'string' && + c.name.length > 0 && + (c.actions?.length ?? 0) > 0 && + c.enabled !== false && + !liveConnectorNames.has(c.name), + ) + .map((c) => c.name as string); +} + /** * AutomationServicePlugin — Core engine plugin * @@ -269,6 +314,10 @@ export class AutomationServicePlugin implements Plugin { // flows that vanished so their jobs stop. ctx.hook('metadata:reloaded', async () => { await this.resyncFlowsFromProtocol(ctx); + // A Studio publish / dev reload can introduce new declarative + // connector entries — re-audit so the inert-descriptor warning + // stays current (see auditDeclaredConnectors). + this.auditDeclaredConnectors(ctx); }); // ── Cold-boot bind via the PROTOCOL's flattened flow view ───────────── @@ -289,6 +338,10 @@ export class AutomationServicePlugin implements Plugin { // flows — is registered). registerFlow is idempotent with the boot pull. ctx.hook('kernel:ready', async () => { await this.syncFlowsFromProtocol(ctx); + // Every plugin's init()/start() has completed here, so connector + // plugins have registered their runtime connectors — the earliest + // point the declared-vs-registered comparison is meaningful. + this.auditDeclaredConnectors(ctx); }); // ADR-0019 follow-up: re-arm auto-resume timers for runs that were @@ -311,6 +364,41 @@ export class AutomationServicePlugin implements Plugin { } } + /** + * Descriptor-only contract audit (#2612) — warn, once per boot/reload, + * about declarative `connectors:` entries that declare actions but have no + * runtime registration (see {@link findInertDeclaredConnectors}). Reads + * the same ObjectQL registry `registerApp` writes declarative connector + * metadata into. Best-effort: without an ObjectQL registry there is + * nothing declared, hence nothing to audit. + */ + private auditDeclaredConnectors(ctx: PluginContext): void { + if (!this.engine) return; + let declared: unknown[] = []; + try { + const ql = ctx.getService<{ + registry?: { listItems?: (type: string) => unknown[] }; + }>('objectql'); + declared = ql?.registry?.listItems?.('connector') ?? []; + } catch { + return; + } + if (declared.length === 0) return; + const live = new Set(this.engine.getConnectorDescriptors().map((d) => d.name)); + const inert = findInertDeclaredConnectors(declared, live); + if (inert.length === 0) return; + ctx.logger.warn( + `[Automation] ${inert.length} declarative connector(s) declare actions but are not registered ` + + `in the connector registry — the connector_action node cannot dispatch them: ${inert.join(', ')}. ` + + `Declarative \`connectors:\` entries are catalog descriptors (descriptor-only contract, #2612); ` + + `runtime connectors are contributed by plugins via engine.registerConnector() — e.g. ` + + `@objectstack/connector-rest, @objectstack/connector-slack, @objectstack/connector-openapi, ` + + `@objectstack/connector-mcp. Install/instantiate the matching connector plugin, or mark a ` + + `deliberate catalog-only entry with \`enabled: false\` to silence this warning. ` + + `Declarative provider-bound connector instances are tracked in #2977 (ADR-0096).`, + ); + } + /** * Read the protocol's flattened flow view — `getMetaItems({ type: 'flow' })`, * the same source `GET /meta/flow` serves and #2560's cold-boot bind uses. diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 630710b420..f81b7bc3ab 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -24,7 +24,28 @@ import { FieldMappingSchema as BaseFieldMappingSchema } from '../shared/mapping. * * This protocol supports multiple authentication strategies, bidirectional sync, * field mapping, webhooks, and comprehensive rate limiting. - * + * + * ## Runtime contract — descriptor vs. registered connector (#2612) + * + * This schema serves TWO distinct consumers; do not conflate them: + * + * 1. **Runtime registration (plugin-only).** The automation engine's connector + * registry — what `GET /connectors` lists and the `connector_action` flow + * node dispatches — is populated exclusively by plugins calling + * `engine.registerConnector(def, handlers)` with a handler per declared + * action (ADR-0018 §Addendum). The definition is validated against this + * schema at registration. + * 2. **Declarative `connectors:` stack entries (catalog descriptors).** Stack + * metadata validated against this schema is registered as kind 'connector' + * for discovery/documentation/marketplace purposes only — it never reaches + * the runtime registry, because an action here carries no execution binding + * (deliberately: ADR-0023 rejected re-inventing OpenAPI inside this schema). + * The automation service warns at boot about declared entries with `actions` + * that lack a same-name runtime registration; mark deliberate catalog-only + * entries with `enabled: false`. Provider-bound declarative instances that + * a generic executor (connector-openapi / connector-mcp) materializes at + * boot are tracked in #2977 (ADR-0096). + * * Authentication is now imported from the canonical auth/config.zod.ts. * * ## When to Use This Layer @@ -607,9 +628,13 @@ export const ConnectorSchema = lazySchema(() => z.object({ status: ConnectorStatusSchema.optional().default('inactive').describe('Connector status'), /** - * Enable connector + * Enable connector. On a declarative `connectors:` stack entry, `false` + * additionally marks a deliberate catalog-only descriptor — it suppresses + * the boot audit warning for declared-but-unregistered connectors (#2612). */ - enabled: z.boolean().optional().default(true).describe('Enable connector'), + enabled: z.boolean().optional().default(true).describe( + 'Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor (#2612).', + ), /** * Error mapping configuration diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 288dbb5062..a61d5eb9c8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -332,8 +332,30 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ /** * Integration Protocol + * + * ⚠ Descriptor-only contract (#2612): entries here are **catalog + * descriptors**, registered as metadata (kind 'connector') for discovery, + * documentation, and marketplace listing — they do NOT reach the automation + * engine's connector registry and the `connector_action` flow node cannot + * dispatch them. Runtime connectors are contributed exclusively by plugins + * calling `engine.registerConnector(def, handlers)` (ADR-0018 §Addendum) — + * e.g. `@objectstack/connector-rest`, `connector-slack`, `connector-openapi` + * (ADR-0023), `connector-mcp` (ADR-0024) in the stack's `plugins:` array. + * + * The automation service audits this at boot: a declared entry with + * `actions` and no same-name runtime registration logs a loud warning. + * Mark deliberate catalog-only entries with `enabled: false`. + * Provider-bound declarative connector *instances* — declarative entries a + * generic executor materializes into live connectors at boot — are tracked + * in #2977 (ADR-0096). */ - connectors: z.array(ConnectorSchema).optional().describe('External System Connectors'), + connectors: z.array(ConnectorSchema).optional().describe( + 'External System Connectors — catalog descriptors only: NOT runtime-dispatchable. ' + + 'The connector_action registry is populated exclusively by connector plugins in `plugins:` ' + + '(connector-rest / connector-slack / connector-openapi / connector-mcp). Declaring a connector ' + + 'here does not make flows able to call it; set `enabled: false` on deliberate catalog-only ' + + 'entries. See #2612; declarative provider-bound instances are tracked in #2977 (ADR-0096).', + ), /** * Data Seeding Protocol