Skip to content

Commit d8f7f6a

Browse files
authored
feat(automation,spec): descriptor-only contract + boot audit for declarative connectors (#2612) (#2985)
Declarative `connectors:` stack entries never reach the automation connector registry (plugins only, via engine.registerConnector — ADR-0018 §Addendum), so a declared connector with actions and no plugin behind it looked dispatchable but was silently inert. Make the contract explicit and audited, and chart the upgrade path. - service-automation: kernel:ready + metadata:reloaded boot audit warns on declared connectors with actions but no same-name runtime registration (findInertDeclaredConnectors + tests). - spec: stack.zod.ts connectors: and integration/connector.zod.ts document the descriptor-vs-registered contract; enabled:false marks a deliberate catalog-only descriptor. - showcase: demonstrates the declarative collection per the contract; flips the STACK_COLLECTION_COVERAGE waiver to demonstrated. - ADR-0096 (Proposed): declarative connector instances (provider binding + factory materialization + credentialRef), tracked in #2977. Closes #2612.
1 parent 28ba0c7 commit d8f7f6a

9 files changed

Lines changed: 518 additions & 8 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(automation): descriptor-only contract + boot audit for declarative `connectors:` (#2612)
7+
8+
Declarative `connectors:` stack entries never reach the automation engine's
9+
connector registry — only plugins populate it via
10+
`engine.registerConnector(def, handlers)` (ADR-0018 §Addendum) — so a declared
11+
connector with actions and no plugin behind it *looked* dispatchable but was
12+
silently inert.
13+
14+
The contract is now explicit and audited:
15+
16+
- **Boot audit (service-automation).** At `kernel:ready` (and again on
17+
`metadata:reloaded`), declared connectors with `actions` but no same-name
18+
runtime registration log a loud warning naming each inert entry and
19+
pointing at the fix (install the matching connector plugin, or mark a
20+
deliberate catalog entry). Nothing is registered on your behalf — the
21+
warning surfaces the gap `connector_action` would otherwise hit at
22+
dispatch time.
23+
- **`enabled: false` = deliberate catalog descriptor (spec).** Setting it on
24+
a declarative entry documents "descriptor-only on purpose" and silences the
25+
audit. Schema docs on `stack.zod.ts` (`connectors:`) and
26+
`integration/connector.zod.ts` now state the descriptor-vs-registered
27+
contract explicitly (including for AI stack authoring via `.describe()`).
28+
29+
Declarative provider-bound connector *instances* — entries a generic executor
30+
(connector-openapi / connector-mcp) materializes into live connectors at boot,
31+
upgrading this warning to a hard error — are specified in ADR-0096 and tracked
32+
in #2977.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# ADR-0096: Declarative Connector Instances — Provider-Bound `connectors:` Entries Materialized by Generic Executors
2+
3+
**Status**: Proposed (2026-07-15)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**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)
6+
**Tracking**: framework#2977 (supersedes the interim descriptor-only contract from framework#2612)
7+
**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)
8+
9+
---
10+
11+
## TL;DR
12+
13+
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.
14+
15+
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.
16+
17+
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`.
18+
19+
---
20+
21+
## Context
22+
23+
### The two connector worlds (#2612)
24+
25+
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.
26+
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.
27+
28+
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.
29+
30+
### Why naive bridging was rejected
31+
32+
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).
33+
34+
---
35+
36+
## Decision
37+
38+
### 1. `provider` on the stack-level connector entry
39+
40+
A declarative connector entry MAY carry a `provider` plus provider-specific config:
41+
42+
```ts
43+
connectors: [{
44+
name: 'billing',
45+
label: 'Billing API',
46+
type: 'api',
47+
provider: 'openapi', // ← which installed executor materializes this
48+
providerConfig: { // provider-specific; validated by the provider
49+
spec: './specs/billing-openapi.json', // openapi: document ref
50+
baseUrl: 'https://billing.example.com',
51+
},
52+
auth: { type: 'bearer', credentialRef: 'billing_api_token' }, // reference, never a secret
53+
}]
54+
```
55+
56+
- **No `provider`** → the entry is a catalog descriptor, exactly the #2612 interim contract (audit warning applies unless `enabled: false`).
57+
- **`provider` present** → the entry is an **instance declaration** and MUST be materialized at boot.
58+
59+
### 2. Providers are factories contributed by connector plugins
60+
61+
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:
62+
63+
1. Look up the factory for `entry.provider`.
64+
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).
65+
3. The service calls `engine.registerConnector(def, handlers)` — the registry and `connector_action` see a finished connector, indistinguishable from a hand-written one.
66+
67+
**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.
68+
69+
### 3. Credentials are references
70+
71+
`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**.
72+
73+
### 4. Two-sources-of-truth rule
74+
75+
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.)
76+
77+
### 5. Non-goals
78+
79+
- **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.
80+
- **L3 aspirational surfaces stay out of scope**: `syncConfig`, `fieldMappings`, `webhooks`, `triggers` on `ConnectorSchema` remain unimplemented by this ADR.
81+
- **No messaging semantics** — the ADR-0022 layering stands; this is transport only.
82+
83+
### 6. Acceptance
84+
85+
- 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.
86+
- The showcase demonstrates the declarative path live (upgrading the catalog-descriptor demo shipped with #2612) and `GET /connectors` lists the materialized instance.
87+
- Boot fails loudly for: unknown provider, invalid providerConfig, unresolvable credentialRef, name conflict with a plugin-registered connector.
88+
89+
---
90+
91+
## Consequences
92+
93+
**Positive**
94+
- The `connectors:` collection stops being the platform's one dead metadata surface; AI can wire integrations without leaving metadata.
95+
- Zero new runtime abstraction: providers reuse the ADR-0023/0024 generator/adapter APIs verbatim.
96+
- The industry-standard definition/credential split (Salesforce Named Credentials, Power Platform connections) lands with the schema, not as a retrofit.
97+
98+
**Negative / costs**
99+
- 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.
100+
- 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).
101+
- Provider factories add a registration surface to connector plugins (small; mirrors how they already self-register).

examples/app-showcase/objectstack.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { allJobs } from './src/automation/jobs/index.js';
3131
import { allEmails } from './src/system/emails/index.js';
3232
import { allBooks } from './src/system/books/index.js';
3333
import { allApis } from './src/system/apis/index.js';
34+
import { allConnectors } from './src/system/connectors/index.js';
3435
import {
3536
allPositions,
3637
allPermissionSets,
@@ -190,6 +191,10 @@ export default defineStack({
190191
// Declarative REST endpoints (object_operation + flow) — the metadata
191192
// counterpart of the code-mounted recalc endpoint (see src/system/apis/).
192193
apis: allApis,
194+
// Declarative connector CATALOG DESCRIPTORS (#2612) — metadata-only, never
195+
// runtime-dispatchable; the live connectors are the plugins above. See
196+
// src/system/connectors/ for the full contract.
197+
connectors: allConnectors,
193198
hooks: allHooks,
194199
webhooks: allWebhooks,
195200

examples/app-showcase/src/coverage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,10 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
176176
'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).',
177177
},
178178
connectors: {
179-
status: 'waived',
180-
reason:
181-
'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.',
182-
issue: 'https://github.com/objectstack-ai/framework/issues/2612',
179+
status: 'demonstrated',
180+
files: ['src/system/connectors/index.ts'],
181+
notes:
182+
'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.',
183183
},
184184
};
185185

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineConnector, type Connector } from '@objectstack/spec/integration';
4+
5+
/**
6+
* Declarative `connectors:` — catalog descriptors (#2612).
7+
*
8+
* A stack's `connectors:` collection is **descriptor-only**: entries register
9+
* as metadata (kind 'connector') for discovery, documentation, and future
10+
* marketplace listing, but they never reach the automation engine's connector
11+
* registry — `connector_action` cannot dispatch them. Live connectors are the
12+
* `plugins:` entries in objectstack.config.ts (ConnectorRestPlugin /
13+
* ConnectorSlackPlugin), which call `engine.registerConnector(def, handlers)`
14+
* (ADR-0018 §Addendum) and are exercised by the connector flows in
15+
* src/automation/flows/.
16+
*
17+
* `enabled: false` marks the entry as a deliberate catalog-only descriptor —
18+
* without it, the automation service's boot audit (rightly) warns that a
19+
* declared connector with actions has no runtime registration.
20+
*
21+
* Declarative provider-bound connector *instances* — entries a generic
22+
* executor (connector-openapi / connector-mcp) materializes into dispatchable
23+
* connectors at boot — are the planned upgrade of this collection, tracked in
24+
* https://github.com/objectstack-ai/framework/issues/2977 (ADR-0096).
25+
*/
26+
export const ErpCatalogConnector = defineConnector({
27+
name: 'showcase_erp_catalog',
28+
label: 'ERP Integration (Catalog Descriptor)',
29+
type: 'saas',
30+
description:
31+
'Catalog-only descriptor documenting a planned ERP integration: what it is, how it authenticates, ' +
32+
'and which actions it will expose. Not dispatchable — see the connector plugins in ' +
33+
'objectstack.config.ts for the live registry entries this collection does NOT feed (#2612).',
34+
authentication: { type: 'api-key', key: 'SET_AT_INSTALL_TIME', headerName: 'X-API-Key' },
35+
// Descriptor-level action catalog: key + label + I/O JSON Schemas. Note the
36+
// deliberate absence of any execution binding (HTTP method/path) — that is
37+
// what keeps descriptors inert today and what ADR-0096's provider binding
38+
// supplies declaratively.
39+
actions: [
40+
{
41+
key: 'get_invoice',
42+
label: 'Get Invoice',
43+
description: 'Fetch a single invoice from the ERP by its number.',
44+
inputSchema: {
45+
type: 'object',
46+
properties: { invoiceNumber: { type: 'string' } },
47+
required: ['invoiceNumber'],
48+
},
49+
outputSchema: {
50+
type: 'object',
51+
properties: {
52+
invoiceNumber: { type: 'string' },
53+
status: { type: 'string' },
54+
totalAmount: { type: 'number' },
55+
},
56+
},
57+
},
58+
{
59+
key: 'post_journal_entry',
60+
label: 'Post Journal Entry',
61+
description: 'Write a journal entry into the ERP general ledger.',
62+
inputSchema: {
63+
type: 'object',
64+
properties: {
65+
account: { type: 'string' },
66+
amount: { type: 'number' },
67+
memo: { type: 'string' },
68+
},
69+
required: ['account', 'amount'],
70+
},
71+
},
72+
],
73+
// Deliberate catalog-only descriptor: suppresses the boot inert-connector
74+
// audit warning (#2612).
75+
enabled: false,
76+
});
77+
78+
export const allConnectors: Connector[] = [ErpCatalogConnector];

0 commit comments

Comments
 (0)