Skip to content
Open
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
29 changes: 29 additions & 0 deletions .changeset/31699-connector-names-per-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/core': minor
'@objectstack/objectql': minor
'@objectstack/metadata': minor
'@objectstack/metadata-protocol': patch
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@objectstack/runtime': minor
---

feat(service-automation)!: two packages of two namespaces may each declare a connector of the same name, each is materialized, connected and dispatched as its own instance, and a flow, a pull and the connection door address one by package (ADR-0150 child B, connector, the narrowing)

Clause-②: yes (narrowing) — ten answers move for a caller that worked before. (1) The install door, `registerItem` and the cold-boot seal admit a package declaring a connector name a package of another namespace holds, FROM `422 PLUGIN_REGISTER_FAILED` naming both (a sealed boot clash warned) TO admitted; `os migrate stored-references` lists no sealed clash for it. (2) The boot of two packages' provider-bound instances of one name (the sealed clash), FROM refused naming both (`connector instance 'NAME' is not materialized`, fatal for the host's boot) TO each materialized under its package, whatever their namespaces: a pair the install door still refuses (one namespace, or a package with no namespace), which a code boot only warns about, is materialized too, and is connectable only in process (`ConnectorConnectionRequest.package`), since the namespace door answers `409` for both. (3) The bare `GET /meta/connector/NAME` of a name two packages hold is refused `409 RESOURCE_CONFLICT` (`ambiguous-in-environment`, naming both), where it served the first registered package's declaration, and `MetadataManager` keeps a slot per `(package, name)` for a connector (`PACKAGE_SLOTTED_STORE_TYPES`), so its bare read of such a name is refused and its unscoped plural reads answer both, where the second package's item overwrote the first's. The history and rollback doors (#33193) read the same list (`PACKAGE_SLOTTED_METADATA_TYPES`, `@objectstack/spec`, which `connector` joins): a connector's `?package=` there, FROM refused `400`, TO the addressed holder's versions; a bare connector name two packages hold, or one package's row beside the environment's (the rule every per-package type keeps), FROM served, TO refused `409` naming both. (4) A bare connector read on a metadata engine that cannot read by package is refused `501` (the protocol's fail-closed read for a narrowed type), where it served the engine's `getItem`. (5) A `connector_action` in a flow with no package (an environment-authored flow) naming a connector two packages hold, FROM not dispatched as "not registered" (`none`), or, where each holder declares only a catalog entry beside a connector a plugin registers in code under the name, dispatched to the plugin's connector, TO refused naming both. (6) A flow whose connector name resolves to a package with no live instance of it (a catalog entry, or an instance the reconcile has not registered yet), FROM dispatched to the one instance of the name (3b's fallback arm) TO not dispatched, unless the one instance is a connector a plugin registers in code, which is served as before. (7) A name only disabled packages hold, whose instance is still registered, FROM dispatched to it TO not dispatched for a caller outside those packages (a flow of the disabled package itself still reads its own instance, as before). A disabled package's own entry is removed by the next reconcile; an entry with no package of its own over such a name stays that package's instance, and is not dispatched until the package is enabled again. (8) A connector pull resolves `connectorSource.connector` from the package that holds the mapping: a name that resolves to no single instance, FROM `400 connector_not_registered` (two instances) TO `409 connector_name_refused` naming every candidate; a name that resolves to a package with no instance, FROM pulled through the one instance of the name TO `400 connector_not_registered`. (9) A declared entry with no package of its own (unstamped, or the environment's stored row) under a name two packages hold, FROM the whole name refused TO this entry alone refused naming both, and each package's own instance materialized. (10) Two declared entries of one name in a composition with no package-scoped registry are refused with a sentence that says why (one instance per name where nothing can tell two packages apart), where the duplicate sentence named the package of one of them. Widened beside them: `ConnectorConnectionRequest.package` and `ConnectorDescriptor.packageId` (`@objectstack/spec`, both optional); the namespace connection door `/api/v1/automation/connectors/:ns/:name/connection`, which answered `409` naming both for a name two packages hold, connects, reads and revokes the instance of the addressed namespace's package; `GET /api/v1/automation/connectors` lists every instance, each with its `packageId`; the connector credential listing names each package's instance with its own state.
Metadata-protocol: added

<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed, renamed or re-shaped and no stored metadata changes shape: two optional members are added to two TypeScript interfaces and `connector` joins one exported list of type names, and what moves is which second package an install admits and which instance a name addresses -->

**BREAKING** for the callers in (3) to (8). A deployment that held one connector name under two packages (sealed at boot with a warning, its boot refused) now boots both: read each declaration by its package (`?package=ID`, or `/meta/connector/NS/NAME`), connect each at its namespace address, and have an environment-authored flow name a connector one package holds. A flow that dispatched through 3b's fallback arm (6) dispatches once its own package's instance is materialized. A pull whose mapping no package holds, naming a connector two packages hold, names a mapping in one of the packages. Nothing in the workspace dispatches through (6) or (7) in a supported state: the reconcile registers a package's instance under its package, and drops a disabled package's own entry on its next run. An entry with no package of its own over a name only a disabled package holds is not dispatched while that package is disabled (7).

The bare-name one-holder rule (`bare-name-one-holder.ts`) has narrowed for `connector`, the last covered type, from one package holder per deployment to one per namespace (ADR-0150 D2, D9): `connector` joins `NAMESPACE_SCOPED_BARE_NAME_TYPES` (`@objectstack/core`), `PACKAGE_SCOPED_READ_TYPES` (`@objectstack/objectql`) and `PACKAGE_SLOTTED_STORE_TYPES` (`@objectstack/metadata`). Still refused, with the same envelope and sentence: a co-owner of the holder's namespace, a package that declares no namespace beside any other holder, and two packages of one shareable namespace.

- **The reconcile** (`plugin.ts`): the one-holder backstop (`refusedDeclaredConnectorNames`, and `refusedConnectorNames` in `connector-package-scope.ts`) is gone. Each provider-bound entry is the instance of its own package; an entry with no package of its own is the instance of the package its name resolves to over every holder, a disabled one included (the durable judgment), and under two holders it is refused naming both, never registered as either. Its bookkeeping (desired, materialized, degraded, credential-refusal, retry, dematerialize) and the OAuth2 grant handles are keyed by instance (`connectorInstanceKey`: the name, or `PACKAGE/NAME`), so two packages' instances of one name are materialized, degraded, retried and connected each on its own. A name repeated within one package is refused as a duplicate, naming the package. A composition with no package-scoped registry keeps one instance per name (red line 1: nothing there can tell two packages apart). A reloaded artifact supersedes the registry's entries by `(package, name)` (an unstamped item is its manifest's), so a reload of one package leaves another package's instance of the name live (an artifact with no manifest id, or a composition with no package-scoped registry, still supersedes by name, as before); the inert-descriptor audit and the credential listing judge each entry by its own instance.
- **Dispatch** (`engine.ts`, `resolveConnectorInstanceFor`): every caller asks the resolver over the served holders, a caller with no package included. `refused` is said with the resolver's message; `resolved` addresses exactly the holder's instance, and with none registered under it, the connector a plugin registers in code under the name (ADR-0097 §4: a plugin's connector and a declarative instance never share a name), else nothing. A name only disabled packages hold answers the same way (#31699 notes 6816295828 and 6820178514: resolve and count over the served set; a disabled package's instance is never served). A name the registry holds nothing under, or a host with no resolver, is answered by the one instance of the name, as before.
- **The pull** (`connector-pull.ts`): the mapping's connector is resolved from the mapping's holder package (an environment mapping: as a caller with no package) through the registry's `resolveConnectorInstanceFor`, and its origin, degraded state, provider, action and `inputSchema` are read from that instance. A registry without the member is read by the bare name, as before. New refusal reason `connector_name_refused`.
- **The connection door**: `ConnectorConnectionRequest.package` names the instance; the runtime's namespace address admits a connector name two packages hold to the addressed holder (`admitAutomationAddress` answers a slot for a connector as it does for a flow) and passes the package. The bare door keeps its `409` naming both. A callback completes the package its begin recorded, as before.
- **Descriptors**: `ConnectorDescriptor.packageId` is set for an instance a package holds; the credential listing matches each declaring entry to its own instance.
- **#33218 review N6** (judged, pinned): a connection row a moved declaration left stamped with its old package, once that package declares the name again beside the new holder, is that package's own again and the new holder has none. The row is not restamped: its token was obtained for the old package's connect.

One holder: every answer is as before except (6), (7), and an unstamped entry whose holder is replaced, which is not dispatched between the change and the next reconcile (FROM the instance registered under the replaced package).
47 changes: 45 additions & 2 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -759,8 +759,10 @@ a rotating refresh token is never spent twice.
an instance the environment authored or a plugin registers); it is
not a sign-in account, and signing in never touches it. A callback
completes the instance its connect was begun for, never the name read
again, and the connect door refuses with `409` a name two packages hold
rather than pick one. A run dispatches
again, and the bare connect door refuses with `409` a name two packages hold
rather than pick one: connect each package's instance at its namespace
address, `/api/v1/automation/connectors/NS/NAME/connection` (see
[Two packages, one connector name](#two-packages-one-connector-name)). A run dispatches
with its own organization's connection; a run with no organization uses the
instance's only connection, and is refused when there are several. When the
grant stops working, the connection is marked **reconnect required** and the
Expand Down Expand Up @@ -935,6 +937,45 @@ A materialized instance is dispatched exactly like any other connector — by
See [Flows](/docs/automation/flows) for the node reference and how outputs
flow into downstream nodes.

### Two packages, one connector name

Two packages that declare **different namespaces** may each declare a
connector of the same `name`. Each is its own instance, held by its package
and name: each is materialized, connected, credentialed and listed on its own
(`GET /api/v1/automation/connectors` lists both, each descriptor carrying its
`packageId`). Two packages of **one** namespace still may not, nor a package
that declares no namespace beside another holder: the install is refused,
naming both, as before. A deployment that already holds such a pair (code
packages a boot only warns about) materializes each instance under its
package, but no HTTP address tells them apart: the namespace door answers
`409` for both. Give one of them its own namespace.

A bare name is resolved for whoever names it, and never picked between two
holders:

| Who names the connector | Which instance it means |
|:---|:---|
| A flow's `connector_action` (`connectorId`) | The flow's own package's instance, else the one holder in the package's declared dependencies; refused, naming every candidate, when its reach holds two or holds it only outside |
| A flow the environment authored | The environment's own instance, else the one package holder; refused, naming both, when two packages hold it |
| A connector pull (`mapping.connectorSource.connector`) | Resolved as a flow is, from the package that holds the mapping (an environment mapping: as the environment's flow). A refused name fails the pull `409` (`connector_name_refused`); a name that resolves to a package with no live instance is `connector_not_registered`, never another package's instance |
| The connection door, `/api/v1/automation/connectors/NAME/connection` | The one holder of the name; `409` naming both when two packages hold it |
| The connection door at the namespace address, `/api/v1/automation/connectors/NS/NAME/connection` | The instance of the package of namespace `NS`: connect, read and revoke each package's connection there |
| `GET /api/v1/meta/connector/NAME` | `409` naming both when two packages hold it; read each with `?package=ID` or `/meta/connector/NS/NAME` |

Each package's instance has its own connection per organization: connecting
one leaves the other's untouched, and revoking one leaves the other serving.

A connector a plugin registers in code is no package's: it is dispatched by
its name as before. A package may still declare a catalog descriptor of that
name (an entry without a `provider`); the package's flows are then served the
plugin's connector.

A disabled package holds no name for anyone else: its instance is not served
to a caller outside the package, even before the next reconcile removes it. A
flow of the disabled package itself still reads its own instance. An entry
with no package of its own over a name only a disabled package holds is that
package's instance, and is not dispatched until the package is enabled again.

## Failure modes at a glance

Boot distinguishes **configuration faults** (your entry is wrong — fail loud,
Expand All @@ -954,6 +995,8 @@ retry):
| A response to an `alipay-v3` call is unsigned (below status 400) or its signature does not verify | The dispatch **fails** naming the instance and the status; the response's body is never returned |
| The upstream refuses the credential (`401` / `403`), or an `mcp` server sent a static credential does not list a tool the instance's `include` names | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff |
| Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds |
| Two entries of one package share a `name` | **Hard boot error** naming the name and the package (a stored entry is logged and skipped): a name is unique within a package |
| An entry with no package of its own (one the environment stored, or an unstamped one) shares a `name` two packages hold | **Not materialized**, refused naming both holders, since which package's instance it would be cannot be told: a host's own entry fails the boot, a stored or installed one is logged and skipped. Declare it in one of the packages, or under another name |
| stdio transport without host opt-in | **Hard boot error** with the `declarativeStdio` fix inline — a security rejection is never retried |
| An entry of an **installed** package (`os package install`, `POST /api/v1/marketplace/install-local`) names a package-relative file — an `openapi` `spec` path — or needs a location inside its package, as a stdio `mcp` transport's working directory does | Configuration fault, **never resolved against the host's directory**: an installed package has no directory on the runtime it was installed into, and the host's is not its own. The instance is not materialized, and the reconcile's error names the entry, its package and the ref. Rebuild the package with a current `os build`, which carries an `openapi` spec file into the artifact, or inline the document; reach an MCP server from an installed package over `http`. Whether an entry is the host's is read from what the install recorded, never from the package's own words: an entry carrying a `_packageId` other than its own package's is refused at the install, every member of a `packages[]` bundle is recorded as installed whatever its `source` says, and an entry naming a package no registration records is refused its ref too. The host's own entries keep reading their files |
| A **stored** entry — one saved in this environment through the metadata door (`PUT /api/v1/meta/connector/:name`, Studio), environment-wide or in a package created here (`POST /api/v1/packages`) — names a package-relative file or needs a location inside a package | Configuration fault, **never resolved against the host's directory**: a stored definition is no package's declaration and has no directory on disk. The instance is not materialized, and the reconcile's error names the entry, its package if it has one, and the ref. This one is never fatal, even at boot: a stored row at rest is logged and skipped, and the platform starts. Carry the document inline in the definition, or name it by an `http(s)` URL; reach an MCP server from a stored entry over `http`. Only what the host's own stack declares reads its files: a stored row is never read as the host's, whatever `_packageId` its body carries |
Expand Down
Loading
Loading