Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/declarative-connectors-2612-descriptor-contract.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 101 additions & 0 deletions docs/adr/0096-declarative-connector-instances.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,

Expand Down
8 changes: 4 additions & 4 deletions examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,10 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
'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.',
},
};

Expand Down
78 changes: 78 additions & 0 deletions examples/app-showcase/src/system/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -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];
Loading