Skip to content

Commit 96a14d0

Browse files
authored
feat(connectors): ADR-0096 — provider-bound declarative connector instances (#2977) (#2994)
Declarative `connectors:` entries may now name a `provider` (openapi/mcp/rest); the automation service materializes them into live, dispatchable connectors at boot, reusing the ADR-0023/0024 generator APIs. credentialRef references (never inline secrets) resolve via a pluggable CredentialResolver (open-tier default: env vars). Boot fails loudly for unknown provider / invalid providerConfig / unresolvable credentialRef / name conflict. Additive/loosening only — no migration. Closes the last mile of ADR-0096.
1 parent 929efdf commit 96a14d0

29 files changed

Lines changed: 2046 additions & 110 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/connector-rest': minor
5+
'@objectstack/connector-openapi': minor
6+
'@objectstack/connector-mcp': minor
7+
---
8+
9+
feat(connectors): ADR-0096 — provider-bound declarative connector instances materialized at boot (#2977)
10+
11+
Declarative `connectors:` stack entries used to be **descriptor-only** (#2612):
12+
registered as metadata but never dispatchable, the platform's one dead metadata
13+
surface. An entry may now name a **`provider`** — an installed generic executor
14+
(`openapi` / `mcp` / `rest`) — and the automation service **materializes** it
15+
into a live, dispatchable connector at boot. AI can now wire an integration as
16+
pure metadata and a flow `connector_action` calls it end-to-end.
17+
18+
- **Schema (`@objectstack/spec`).** `ConnectorSchema` gains `provider`,
19+
`providerConfig`, and `auth` (a `credentialRef`-based instance-auth shape —
20+
`ConnectorInstanceAuthSchema` — that references credentials, never inlines
21+
them); `authentication` now defaults to `{ type: 'none' }` so a provider-bound
22+
instance need not author it (loosening — existing connectors are unaffected).
23+
`DeclarativeConnectorEntrySchema` (used by `stack.zod.ts`) rejects inline
24+
secrets, orphan `providerConfig`/`auth`, and authored `actions`/`triggers` on a
25+
provider-bound entry. A new `integration/connector-provider.ts` defines the
26+
provider-factory contract as pure types.
27+
28+
- **Engine + boot (`@objectstack/service-automation`).** The engine adds a
29+
connector-provider registry (`registerConnectorProvider`/`getConnectorProvider`)
30+
and origin-tags registered connectors. At boot the service resolves each
31+
provider-bound entry — looking up the factory, resolving `auth.credentialRef`
32+
via a pluggable `CredentialResolver` (open-tier default: environment
33+
variables), and registering the materialized connector. Boot **fails loudly**
34+
for an unknown provider, invalid `providerConfig`, an unresolvable
35+
`credentialRef`, or a name conflict with a plugin-registered connector (no
36+
silent precedence).
37+
38+
- **Providers (`connector-rest` / `connector-openapi` / `connector-mcp`).** Each
39+
plugin registers a provider factory in `init()` reusing its existing
40+
generator/adapter API. Plugin options are now **optional**: with none the
41+
plugin contributes only its provider factory; with instance options it also
42+
registers a hand-wired connector (back-compat). `connector-openapi` adds a
43+
`ConnectorOpenApiPlugin`.
44+
45+
Open tier: static auth (`none`/`api-key`/`basic`/`bearer`) with `credentialRef`
46+
resolved from env vars. Managed vaulting, OAuth2 refresh, and per-tenant
47+
connection lifecycle remain the enterprise tier (ADR-0015) — an enterprise host
48+
injects a vault-backed `CredentialResolver` with no change to the materialization
49+
path.

docs/adr/0096-declarative-connector-instances.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0096: Declarative Connector Instances — Provider-Bound `connectors:` Entries Materialized by Generic Executors
22

3-
**Status**: Proposed (2026-07-15)
3+
**Status**: Accepted (2026-07-15) — implemented in framework#2977
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**Tracking**: framework#2977 (supersedes the interim descriptor-only contract from framework#2612)
@@ -99,3 +99,27 @@ If a provider-bound instance and a plugin-registered connector share a `name`, b
9999
- 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.
100100
- 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).
101101
- Provider factories add a registration surface to connector plugins (small; mirrors how they already self-register).
102+
103+
---
104+
105+
## Implementation (framework#2977)
106+
107+
| Decision | Where it landed |
108+
|:---|:---|
109+
| 1. `provider` / `providerConfig` / `auth` on the entry | `@objectstack/spec``integration/connector.zod.ts` (`ConnectorSchema` gains the three fields; `authentication` now defaults to `{ type: 'none' }`); `shared/connector-auth.zod.ts` (`ConnectorInstanceAuthSchema` — the `credentialRef` shapes; `ResolvedConnectorAuth`). Authoring rules (`DeclarativeConnectorEntrySchema`, used by `stack.zod.ts`) reject inline secrets, orphan `providerConfig`/`auth`, and authored `actions`/`triggers` on a provider-bound entry (§5). |
110+
| 2. Provider factory registry | `@objectstack/spec``integration/connector-provider.ts` (`ConnectorProviderFactory` / `ConnectorProviderContext` / `ConnectorMaterialization`, pure types so plugins depend only on the spec). The engine adds `registerConnectorProvider` / `getConnectorProvider` (`service-automation/src/engine.ts`). |
111+
| 3. Boot materialization + `credentialRef` | `service-automation/src/plugin.ts``materializeDeclaredConnectors()` runs in `start()` (a throw there is fatal to bootstrap under both `LiteKernel` and `ObjectKernel`, unlike a swallowed `kernel:ready` hook). `credentialRef` resolves via a `CredentialResolver`; the open-tier default reads env vars. |
112+
| 4. Conflict rule | `registerConnector` is origin-tagged (`plugin` vs `declarative`); a cross-origin name collision throws instead of silently replacing. |
113+
| 5. Provider implementations | `connector-rest` (`rest`), `connector-openapi` (`openapi`), `connector-mcp` (`mcp`) each export a `create*ProviderFactory` and register it in the plugin's `init()`. The plugins now take **optional** options: with none they contribute only the provider factory; with instance options they also register a hand-wired connector (back-compat). |
114+
| 6. Showcase | `examples/app-showcase``StatusApiConnector` (`provider: 'rest'`) is materialized at boot and dispatched by `ShowcaseDeclarativeConnectorPingFlow`; `coverage.ts` records it. |
115+
116+
### Open / enterprise line (ADR-0015)
117+
118+
- **Open source:** the `rest` / `openapi` / `mcp` provider factories; **static** auth (`none` / `api-key` / `basic` / `bearer`); `credentialRef` resolved from **environment variables** (`defaultEnvCredentialResolver`) — the degraded-but-honest story for environments with no managed secrets service.
119+
- **Enterprise:** managed credential **vaulting** and OAuth2 authorization-code/refresh lifecycle (inject a vault-backed `CredentialResolver` via `AutomationServicePluginOptions.credentialResolver` — no change to the materialization path), plus per-tenant connection lifecycle. The open tier deliberately omits OAuth2 from `ConnectorInstanceAuthSchema`.
120+
121+
### Deliberate scope boundaries
122+
123+
- **Boot-time only.** Materialization runs once at boot. Re-materializing a provider-bound instance published at runtime (Studio) is a follow-up; the descriptor audit still re-runs on `metadata:reloaded`.
124+
- **`providerConfig.spec` (openapi)** accepts an inline document or an http(s) URL; resolving a `./file.json` ref relative to the stack is the stack loader's job, not the connector's.
125+
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,57 @@ export const TaskCompletedRestPingFlow = defineFlow({
394394
],
395395
});
396396

397+
/**
398+
* Declarative Connector Ping — the worked ADR-0096 example: a `connector_action`
399+
* dispatching a **provider-bound declarative connector instance**.
400+
*
401+
* Where {@link TaskCompletedRestPingFlow} targets the `rest` connector a *plugin*
402+
* registered (ConnectorRestPlugin, ADR-0018 §Addendum), this flow targets
403+
* `showcase_status_api` — a connector declared as pure metadata in
404+
* src/system/connectors/ and *materialized* into the registry at boot by the
405+
* `rest` generic executor (ADR-0096). Nothing registered it in code: the
406+
* `connectors:` entry named `provider: 'rest'`, and the automation service turned
407+
* it into a live connector. On task creation the flow issues `GET /api/v1/health`
408+
* through it; the call and its `{ status: 'ok' }` response are captured on the
409+
* flow run, proving the declarative path dispatches end-to-end.
410+
*/
411+
export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
412+
name: 'showcase_declarative_connector_ping',
413+
label: 'Declarative Connector Ping (ADR-0096)',
414+
description:
415+
'Dispatches GET /api/v1/health through showcase_status_api — a provider-bound connector instance materialized from pure metadata at boot.',
416+
type: 'autolaunched',
417+
nodes: [
418+
{
419+
id: 'start',
420+
type: 'start',
421+
label: 'On Task Created',
422+
config: {
423+
objectName: 'showcase_task',
424+
triggerType: 'record-after-create',
425+
},
426+
},
427+
{
428+
id: 'ping',
429+
type: 'connector_action',
430+
label: 'GET /api/v1/health (declarative)',
431+
connectorConfig: {
432+
connectorId: 'showcase_status_api',
433+
actionId: 'request',
434+
input: {
435+
method: 'GET',
436+
path: '/api/v1/health',
437+
},
438+
},
439+
},
440+
{ id: 'end', type: 'end', label: 'End' },
441+
],
442+
edges: [
443+
{ id: 'e1', source: 'start', target: 'ping' },
444+
{ id: 'e2', source: 'ping', target: 'end' },
445+
],
446+
});
447+
397448
/**
398449
* Task Follow-up Reminder — the worked `wait` (durable timer) example.
399450
*
@@ -1275,6 +1326,7 @@ export const allFlows = [
12751326
TaskAssignedNotifyFlow,
12761327
ScheduledDigestFlow,
12771328
TaskCompletedRestPingFlow,
1329+
ShowcaseDeclarativeConnectorPingFlow,
12781330
TaskFollowUpFlow,
12791331
NotifyOwnerSubflow,
12801332
TaskDoneNotifyOwnerFlow,

examples/app-showcase/src/coverage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,9 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
177177
},
178178
connectors: {
179179
status: 'demonstrated',
180-
files: ['src/system/connectors/index.ts'],
180+
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],
181181
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.',
182+
'Both connector kinds are demonstrated. (1) Provider-bound INSTANCE (ADR-0096 / #2977): StatusApiConnector declares `provider: rest` and is materialized into a live, dispatchable connector at boot by ConnectorRestPlugin\'s provider factory — ShowcaseDeclarativeConnectorPingFlow calls it via connector_action and it appears in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.',
183183
},
184184
};
185185

examples/app-showcase/src/system/connectors/index.ts

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,61 @@
33
import { defineConnector, type Connector } from '@objectstack/spec/integration';
44

55
/**
6-
* Declarative `connectors:` — catalog descriptors (#2612).
6+
* Declarative `connectors:` — the collection now holds BOTH kinds (ADR-0096):
77
*
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/.
8+
* 1. **Provider-bound instance** ({@link StatusApiConnector}) — a live,
9+
* dispatchable connector authored as pure metadata. It names a `provider`
10+
* (`rest`) and the automation service materializes it at boot: it looks up
11+
* the provider factory `@objectstack/connector-rest` contributes, applies
12+
* `providerConfig` + the resolved `auth`, and calls
13+
* `engine.registerConnector(def, handlers)` for you. The result is
14+
* indistinguishable from a hand-written connector — `connector_action`
15+
* dispatches it and `GET /connectors` lists it. {@link
16+
* file://../../automation/flows/index.ts | ShowcaseDeclarativeConnectorPingFlow}
17+
* calls it end-to-end. This is the #2977 / ADR-0096 upgrade of what used to
18+
* be a purely descriptor-only collection.
1619
*
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+
* 2. **Catalog descriptor** ({@link ErpCatalogConnector}, the #2612 interim
21+
* contract) — an inert entry for discovery / documentation / marketplace
22+
* listing. It has no `provider`, so it never reaches the connector registry;
23+
* `connector_action` cannot dispatch it. `enabled: false` marks it a
24+
* deliberate catalog-only descriptor and suppresses the boot audit warning
25+
* for a declared-with-actions connector that has no runtime registration.
2026
*
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).
27+
* Runtime connectors may also be contributed directly by plugins calling
28+
* `engine.registerConnector()` (ADR-0018 §Addendum) — the `rest`/`slack`
29+
* `plugins:` entries in objectstack.config.ts, exercised by the connector flows
30+
* in src/automation/flows/.
2531
*/
32+
33+
/**
34+
* ADR-0096 provider-bound instance — declared as pure metadata, materialized
35+
* into a live `rest` connector at boot by ConnectorRestPlugin's provider factory
36+
* (which the plugin registers even though, here, it is also configured with a
37+
* hand-wired `rest` connector). Points at the running server itself, so
38+
* {@link file://../../automation/flows/index.ts | ShowcaseDeclarativeConnectorPingFlow}
39+
* can dispatch `GET /api/v1/health` through it with no external dependency and no
40+
* credentials. `auth: { type: 'none' }` keeps boot self-contained; a real
41+
* upstream would use `auth: { type: 'bearer', credentialRef: '<env var>' }`.
42+
*/
43+
export const StatusApiConnector = defineConnector({
44+
name: 'showcase_status_api',
45+
label: 'Status API (Declarative REST Instance)',
46+
type: 'api',
47+
description:
48+
'Provider-bound declarative connector instance (ADR-0096): authored as metadata, materialized into a live, ' +
49+
'dispatchable `rest` connector at boot. Unlike the ERP descriptor below, this one IS callable from a flow ' +
50+
'connector_action and appears in GET /connectors.',
51+
provider: 'rest',
52+
providerConfig: {
53+
// Points at the running server itself (the showcase dev port is 3000), so
54+
// the dispatch is observable with no external dependency. Kept a literal
55+
// because metadata files don't read env — the env-driven `rest` plugin
56+
// connector in objectstack.config.ts is the tunable one.
57+
baseUrl: 'http://127.0.0.1:3000',
58+
},
59+
auth: { type: 'none' },
60+
});
2661
export const ErpCatalogConnector = defineConnector({
2762
name: 'showcase_erp_catalog',
2863
label: 'ERP Integration (Catalog Descriptor)',
@@ -75,4 +110,4 @@ export const ErpCatalogConnector = defineConnector({
75110
enabled: false,
76111
});
77112

78-
export const allConnectors: Connector[] = [ErpCatalogConnector];
113+
export const allConnectors: Connector[] = [StatusApiConnector, ErpCatalogConnector];

0 commit comments

Comments
 (0)