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
40 changes: 40 additions & 0 deletions .changeset/31699-connection-key-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/service-automation': patch
'@objectstack/driver-mongodb': patch
'@objectstack/cli': patch
---

feat(service-automation, metadata-protocol, driver-mongodb, cli): a connector connection is unique per organization and instance, the instance being its package and name, and the connections that name no package keep one per organization through a runtime guard (ADR-0150 D4, D8 row R11, #31699)

Clause-②: yes (widening) — additive CLI and in-process surfaces: `os migrate plan` lists a second runtime-owned index (and `--json` `schemaSyncIndexes` an entry for it), `os migrate duplicates` probes it, `os migrate stored-references bind` answers the refusal `duplicate` where it threw, and the exports and types listed under In-process APIs grow. The connector doors (the connect door, the OAuth callback, revoke, the connection list) and every automation request answer as before: the store still reads and writes by organization and name, and the one-holder backstop keeps every connector name to one package until #31699's last PR, so no second package's connection is written yet.
Metadata-protocol: none
Skills-docs: `content/docs/automation/connectors.mdx` says a connection is one per organization and instance; `content/docs/deployment/cli.mdx` lists the guard among the runtime-owned indexes and the bind's `duplicate` refusal. `skills/**` teaches "connect once per organization", which still holds per instance.

<!-- adr-0087: not-required (no-migration-prescription) No key of any authorable schema is added, removed, renamed or re-shaped. `sys_connector_connection` is a platform object and its `indexes[]` is not authored metadata; the key change is a schema face the drift plan applies, the guard is a runtime-managed physical index, and no stored row is rewritten, so there is nothing for `objectstack migrate meta` to rewrite. -->

Part of #31699 (ADR-0150 child B, PR 3a: the storage half). The maintainer's ruling on #31699: a connection is unique by package and name. The store, the connect door and the OAuth callback carry the package in the next PR; this one widens the key, stands the guard that keeps the package-less rows to one, and teaches the two writers of the package column what the wider key can refuse.

**The key: `sys_connector_connection`.**

- FROM one connection per `(organization_id, connector_name)` (`uniq_sys_connector_connection_organization_id_connector_name`) TO one per `(organization_id, connector_package, connector_name)`. On SQL the drift plan replaces the old key with one `replace_unique_index`, categorized `safe`, create-before-drop, and only once the guard below stands; until then `driver-sql`'s widening arm keeps the old key, so the table is never without a unique rule over its rows. The guard is built at schema sync after the table's drift is read, so on an upgraded database the old key retires on the NEXT sync that runs safe drift (a dev `autoMigrate: 'safe'` boot, or `os migrate apply`, which lists it). Measured on SQLite and PostgreSQL 16: with the guard, the plan is that one safe op and a re-plan is empty; two packages' connections of one name in one organization are admitted, a second connection of one package's instance is refused, and a second package-less connection is refused. On MariaDB the retirement is read from `driver-sql`'s source, not measured: its carrier column is read as a partial unique on the two columns, and on an upgraded MySQL-family database `connector_package` is a TEXT column, so the wide key is built over the driver's hash shadow.

**The guard: `uniq_sys_connector_connection_packageless`.**

- `connector_package` is NULL on every connection of an instance the environment authored or a plugin registers in code, and of a name the boot backfill could not bind. A SQL UNIQUE treats NULLs as distinct, so for exactly those connections the wider key constrains nothing. The guard keeps them to one per `(organization_id, connector_name)`, as the old key did: two of them would keep two refresh tokens for one instance, and a revoke would leave the other stored.
- Built as the permission-set grant's guard is (#32164), at `objectql:schema-synced`, on every kernel that runs the table's DDL; a pass that holds its DDL back builds nothing, a standing guard costs one catalog read, a refusal is reported once per boot. SQLite and PostgreSQL: a partial unique index `WHERE connector_package IS NULL`. MySQL 8.0.13+: a functional key part over the hash-shadow digest. MariaDB 10.3+: a UNIQUE key over an INVISIBLE generated column `uniq_sys_connector_connection_packageless__hash`. MongoDB: `driver-mongodb` builds it at collection sync (`RUNTIME_OWNED_UNIQUES`, `connector_package: null`), keyed `(connector_name)`, because Mongo materialized the earlier declaration's one column, so the widening arm there retires the old index once the guard stands.
- Refuse, never keep-one: a database already holding two package-less connections of one key keeps both; the build fails and logs, at `error`, the keys, the consequence and the query that lists them. `os migrate duplicates` lists this index too. It admits nothing the old key refused.

**The writers of the table.**

- The connection store's first connect (`ConnectorConnectionStore#connect`): the old key refused a second row of one instance in one organization; the wide key admits one when the boot backfill stamped the first connect's row with its package between a second connect's read and its insert. A connect that counts a second row after its insert now deletes its own row and is refused ("its connection changed while this connect was completing … begin the connect again"), nothing kept, as the old key refused it; when both racing connects count two rows, both can be refused and no row stands. Where the engine answers no id for the inserted row, or the delete fails, the connect is refused saying the instance was connected twice and to revoke until it reads as not connected. A first connect with no race is unchanged.
- The boot backfill (`stored-reference-package-backfill.ts`, row R11) binds a package-less connection to the one package that holds its name. Under the wider key that row can be a second row of one key, when a connection bound to that package already stands for the same organization and name (a database where a guard build was refused, `driver-memory`, a server with no form, or a pair a racing first connect left when its process died before taking its row back). The wider key makes that unique violation possible, and it would end R11's pass; it is now reported per row: the row is left as it is and listed in the report's new `duplicated` list, once a read confirms the row of that key that already names the holder, named in one `warn` line per pass. A dry run lists the same rows, counting the keys it has already bound in the pass. Any other failed write, a violation no such row confirms, and any violation on R8 or R9 (whose keys do not carry the package) still end the pass.
- `os migrate stored-references bind`: FROM a unique violation thrown as an error TO the refusal `duplicate`, naming the row, the package and the key, nothing written.

**Declared limits.**

- MySQL 8, upgraded databases: `driver-sql` reads the guard's functional key part as an expression, not as a partial guard, so the widening arm keeps the old key there. The old key keeps refusing what it refused, so an organization cannot connect a second package's instance of one name on such a database until `driver-sql` recognises the form. A database created fresh on MySQL 8 has the wide key and the guard and no old key. The permission-set grant's guard (#32164) shares this limit. MySQL before 8.0.13 and MariaDB before 10.3 build no form, keep the old key (or build it under the driver's generated name where it is missing) and say so at `error` each boot.
- `driver-memory` builds no guard (no raw-SQL seam), as for the grant: a pin measures that its unique check treats a NULL package as distinct, so two package-less connections of one instance are admitted there.
- The MySQL, MariaDB and real-mongod pins run in suites that run on `main` only, so they first run after this lands.

**In-process APIs.** `@objectstack/metadata-protocol` exports `ensureConnectorConnectionPackagelessIndex`, `createConnectorConnectionGuardAtSchemaSync`, `buildConnectorConnectionPackagelessIndexSql`, `buildConnectorConnectionPackagelessIndexPresenceSql`, `buildConnectorConnectionPackagelessDuplicateProbeSql`, `buildConnectorConnectionOldKeySql`, `resolveConnectorConnectionIndexSeam`, `CONNECTOR_CONNECTION_PACKAGELESS_INDEX_NAME`, `CONNECTOR_CONNECTION_PACKAGELESS_INDEX_COLUMNS`, `CONNECTOR_CONNECTION_OLD_KEY_INDEX_NAME` and the result, status and pass-outcome types. `@objectstack/service-automation`'s `StoredReferenceBackfillReport` gains `duplicated`. `@objectstack/cli`'s `BindRefusal` gains `'duplicate'`. All additive.
4 changes: 3 additions & 1 deletion content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,9 @@ a rotating refresh token is never spent twice.
(`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`,
or the automation plugin's `connectorOAuthRedirectUri`), never the
request's; register that URI with the vendor. The refresh token is kept
encrypted in `sys_connector_connection`, one row per organization; it is
encrypted in `sys_connector_connection`, one row per organization and
instance, an instance being its package and name (one per organization for
an instance the environment authored or a plugin registers); it is
not a sign-in account, and signing in never touches it. 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
Expand Down
32 changes: 23 additions & 9 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1189,23 +1189,37 @@ idempotent — but only the second takes time proportional to your data.

#### Runtime-owned indexes apply builds

`os migrate apply` also builds one index of its own once that DDL is done: the
permission-set grant's package-less guard, which keeps one grant per user,
permission set and organization among the grants that name no package.
`os migrate plan` lists it in a section after the held-back work and before the
drift, the order `apply` performs them in:
`os migrate apply` also builds two indexes of its own once that DDL is done:
the permission-set grant's package-less guard, which keeps one grant per user,
permission set and organization among the grants that name no package, and the
connector connection's, which keeps one connection per organization and
connector instance among the connections that name no package.
`os migrate plan` lists them in a section after the held-back work and before
the drift, the order `apply` performs them in:

```text
Runtime-owned indexes (built when you apply, after the work above)
+ sys_user_permission_set.uniq_sys_user_permission_set_packageless [unique (user_id, permission_set, organization_id) WHERE permission_set_package IS NULL]
+ sys_connector_connection.uniq_sys_connector_connection_packageless [unique (organization_id, connector_name) WHERE connector_package IS NULL]
```

The plan lists it only where `apply` would build it. An index that already
The plan lists each only where `apply` would build it. An index that already
stands is not listed, because `apply` sends it no DDL. Nor is it listed when the
plan has nothing else to apply, because `apply` then changes nothing at all; a
serving boot that runs schema sync builds it instead. Grants that would collide
under the index make the line read as refused, with how many: `apply` leaves
serving boot that runs schema sync builds it instead. Rows that would collide
under an index make its line read as refused, with how many: `apply` leaves
those rows as they are, applies the rest, and `os migrate duplicates` lists them.

Once a guard stands, the next safe sync (or `os migrate apply`) retires the key
it stands beside — the grant's `(user_id, permission_set, organization_id)`, the
connection's `(organization_id, connector_name)` — as one `replace_unique_index`
categorized safe, created before it is dropped. Until then that key stays and
keeps refusing a second package's grant or connection of one name. On an
upgraded MySQL database both old keys stay, because `driver-sql` does not read a
guard's functional key part as a guard there, so a second package's grant or
connection of one name stays refused on MySQL. A database created fresh on
MySQL never has the old keys.

Under `--json` the section is the `schemaSyncIndexes` array, whose entries carry
`table`, `index`, `keyParts`, `rowScope` and a `status` of `build`, `blocked` or
`unreadable`.
Expand Down Expand Up @@ -1466,7 +1480,7 @@ where the data lives.
| `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run |
| `os migrate security-catalog-overlays` | List the environment-wide permission-set and position rows (the legacy plurals `permissions` / `positions` included) stored over a name a configured package holds — the rows a v18 cold boot refuses — each with the package that holds its name; `--apply` deletes them, one history tombstone per row. Run it before the first v18 boot, with the flags and environment the deployment boots with: the platform security plugin's sets are held only where `os serve` composes it (an auth secret set, and the `auth` tier on), and the step takes `serve`'s `--preset` and `--dev` with `serve`'s meaning. Adopts nothing; exits 1 while it lists a row |
| `os migrate packageless-overlays` | List the package-less view, dashboard, report, translation and email-template rows stored over a name exactly one installed package ships — the customizations Setup saved before ADR-0146 D11 closed that write — and with `--apply` move each into that package's paired `<base>.custom` (created when absent) as a declared override, `basedOn` the base's installed version; the package-less row is deleted only after the moved one reads back as served. A name no package ships is environment metadata and is not touched; a name several packages ship, or one the pair already holds a row of, is listed and left with the reason. So is a view container (`list`, `listViews`, `form`, `formViews`): only a single view declares an override, so it is listed `cannot_declare` and never attempted, with the way out — save each of its views into the pair as a single view, then delete the container row. Takes `serve`'s `--preset` and `--dev`. Idempotent: a second run moves nothing. Run it once after upgrading |
| `os migrate stored-references` | List the stored references the upgrade left naming no package because the one resolution rule (ADR-0150 D3) refuses them: a user's permission-set assignment, an audience-binding suggestion, a managed sharing rule, a queue membership, an open approval request, a paused flow run, a flow credential, a flow dispatch key, a connector connection, an assignment cursor or a legacy email template whose name more than one package holds, each with its row id and every candidate by its qualified address (`namespace/name`). A suggestion a package wrote naming an environment-authored permission set, or one held only outside the package's declared dependencies, is listed with the fix and is never bindable. Also lists the packaged fields whose picklist the rule refuses (served with no options) the names two packages held before the one-holder rule, and each installed package whose namespace spells a literal segment of the address grammar (`actions`, `connectors`, `global`, `hooks`, `oauth`, `runs`, `trigger`): the boot keeps it with one warning, its items are read with `?package=ID`, and only a rename of its namespace gives them qualified addresses at the door that reads the literal (for `runs` that door is the inbound hook alone, reached at its bare address until then). Read-only, and `--json` prints the same bytes on every run. Run before the release's first boot, it reads a table that has no package column yet as nothing bound yet, lists the rows that boot's backfill will leave unbound, and says so once; it adds no column. A table this composition does not carry (automation, approvals) is named, not skipped: compose as `os serve` does, with `--preset`/`--dev`. Then `os migrate stored-references bind --table T --id ID --package P` writes the package you chose into that one row: it asks the rule again first and refuses, writing nothing, when the row is already bound, now resolves on its own, or the package is no longer a candidate (a stale report). Each bind leaves a `sys_audit_log` row; a second run writes nothing. Confirm with `--yes` |
| `os migrate stored-references` | List the stored references the upgrade left naming no package because the one resolution rule (ADR-0150 D3) refuses them: a user's permission-set assignment, an audience-binding suggestion, a managed sharing rule, a queue membership, an open approval request, a paused flow run, a flow credential, a flow dispatch key, a connector connection, an assignment cursor or a legacy email template whose name more than one package holds, each with its row id and every candidate by its qualified address (`namespace/name`). A suggestion a package wrote naming an environment-authored permission set, or one held only outside the package's declared dependencies, is listed with the fix and is never bindable. Also lists the packaged fields whose picklist the rule refuses (served with no options) the names two packages held before the one-holder rule, and each installed package whose namespace spells a literal segment of the address grammar (`actions`, `connectors`, `global`, `hooks`, `oauth`, `runs`, `trigger`): the boot keeps it with one warning, its items are read with `?package=ID`, and only a rename of its namespace gives them qualified addresses at the door that reads the literal (for `runs` that door is the inbound hook alone, reached at its bare address until then). Read-only, and `--json` prints the same bytes on every run. Run before the release's first boot, it reads a table that has no package column yet as nothing bound yet, lists the rows that boot's backfill will leave unbound, and says so once; it adds no column. A table this composition does not carry (automation, approvals) is named, not skipped: compose as `os serve` does, with `--preset`/`--dev`. Then `os migrate stored-references bind --table T --id ID --package P` writes the package you chose into that one row: it asks the rule again first and refuses, writing nothing, when the row is already bound, now resolves on its own, or the package is no longer a candidate (a stale report), and refuses (`duplicate`) a bind the table's unique key refuses because a row bound to that package already holds the key, such as a connector connection the instance already has in that organization. Each bind leaves a `sys_audit_log` row; a second run writes nothing. Confirm with `--yes` |
| `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes no row and no schema; run it before the boot-time tenancy repair, which overwrites part of the evidence |

**The boot itself writes no row and no schema you did not ask for.** Each of these commands boots
Expand Down
Loading
Loading