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
14 changes: 14 additions & 0 deletions .changeset/27448-connector-webhooks-refusal-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

fix(spec): the retired `connector.webhooks` refusal describes declared webhooks as they work now: read from the metadata registry, with no `sys_webhook` row

Clause-②: no
Metadata-protocol: none

An author who writes `webhooks` on a connector is refused, and the refusal tells them to declare the webhook in the stack's top-level `webhooks:` collection. It used to add that such a webhook "is materialized into `sys_webhook`". That is no longer how the platform works (ADR-0131 D2): a declared webhook is read from the metadata registry and delivered on record events, and no `sys_webhook` row is written for it. `sys_webhook` holds only the webhooks an organization authors at runtime. An author following the old text looked for a row that never appears.

The refusal now says this. The same correction is made in the protocol-18 upgrade note for the connector resilience family (its replacement and reason) and in the generated connector reference.

Prose only. The refusal itself, its error code, its path and what the connector schema accepts are unchanged.
4 changes: 2 additions & 2 deletions content/docs/references/integration/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ const result = ConnectorSchema.parse(data);
| **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so the webhook dispatcher never read it and never delivered it, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection. The platform reads a declared webhook from the metadata registry and delivers it on record events; it writes no `sys_webhook` row for it (`sys_webhook` holds only the webhooks an organization authors at runtime). Note that declaring it STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down Expand Up @@ -490,7 +490,7 @@ Connector type
| **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **fieldMappings** | `never` | optional | [REMOVED] `connector.fieldMappings` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever moved a value through a connector field mapping: nothing read `source`, `target`, `defaultValue`, `dataType`, `required` or `syncMode`. Delete the key; the `ConnectorFieldMapping` shape leaves with it. Map fields on the sync's TARGET instead: a `mapping`'s `fieldMapping` (`source` → `target`, with a `transform` the import path executes), which its `connectorSource` pulls through. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so the webhook dispatcher never read it and never delivered it, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection. The platform reads a declared webhook from the metadata registry and delivers it on record events; it writes no `sys_webhook` row for it (`sys_webhook` holds only the webhooks an organization authors at runtime). Note that declaring it STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. This edit left `os migrate meta` with the protocol-16.x conversions: apply it by hand, or run the tool on a release that still carries the conversion — https://objectstack.ai/docs/protocol-upgrade. |
| **retryConfig** | `{ strategy?: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; … }` | optional | Retry configuration |
| **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
Expand Down
6 changes: 3 additions & 3 deletions packages/spec/scripts/liveness/proof-registry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,14 @@ export const HIGH_RISK_CLASSES: HighRiskClass[] = [
id: 'webhook-materialization',
label: 'Webhook materialization',
summary:
'a stack/connector-authored webhook is materialized into a dispatchable sys_webhook row on boot (#3461/#3489) — the authored value crosses manifest-decomposition → the ObjectQL registry (type `webhook`) → the bridge → engine.insert, and the bridge was first gated BEHIND the realtime/messaging dispatch guard, silently materializing nothing on a realtime-less boot despite green unit tests.',
'a webhook declared in a stack\'s top-level `webhooks:` collection is dispatched from the metadata registry and writes no sys_webhook row (ADR-0131 D2; the boot seeder of #3461/#3489 is retired) — the authored value crosses manifest-decomposition → the ObjectQL registry (type `webhook`) → the `declaredWebhooks` reader the plugin binds on the data engine alone → the dispatcher, which delivers a matching record event with `object`→`object_name` applied. The proof boots WITHOUT realtime because the first bridge was gated BEHIND the realtime/messaging dispatch guard and silently bound nothing on a realtime-less boot despite green unit tests.',
proofId: 'webhook-materialization',
proofRef: 'packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts#webhook-materialization',
bound: true,
// spec `object` → runtime `object_name` is the representative remap for the
// whole authoring→dispatcher pipeline: its `live` status is only true
// because the materializer runs (engine-only, independent of realtime), and
// the proof boots WITHOUT realtime to pin exactly that.
// because the declared-webhook reader is bound (engine-only, independent of
// realtime), and the proof boots WITHOUT realtime to pin exactly that.
ledgerBindings: [{ type: 'webhook', path: 'object' }],
},
{
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10294,8 +10294,8 @@ const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = {
* had no engine — nothing polled, counted consecutive failures or tripped a
* breaker; `status` was read by nothing (the runtime publishes a COMPUTED
* `state`, and participation is `enabled`); and a webhook nested in a
* connector was never registered as a `webhook` item, so it was never
* materialized into `sys_webhook` or delivered.
* connector was never registered as a `webhook` item, so the webhook
* dispatcher never read it or delivered it.
*
* A pure lossless delete, one notice per stripped key: none of the three ever
* had an effect to preserve. In particular the nested `webhooks` are STRIPPED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ describe('connector resilience family retirement — the tombstones', () => {
});
}

it('the `webhooks` prescription teaches the declared-webhook model: read from the registry, no `sys_webhook` row', () => {
// ADR-0131 D2: a declared top-level webhook is code. The dispatcher reads it
// from the metadata registry and no `sys_webhook` row is written for it, so
// a prescription that says it "is materialized into `sys_webhook`" sends the
// author looking for a row that never appears.
const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, webhooks: AUTHORED.webhooks }), 'webhooks');
expect(issue!.message).toContain('reads a declared webhook from the metadata registry');
expect(issue!.message).toContain('it writes no `sys_webhook` row for it');
expect(issue!.message).not.toMatch(/materiali[sz]ed into `sys_webhook`/);
expect(issue!.message).not.toMatch(/materiali[sz]/);
// The D3 entry an upgrading author reads says the same.
const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'connector-resilience-keys-retired');
expect(entry!.replacement).toContain('reads from the metadata registry');
expect(entry!.replacement).toContain('writes no `sys_webhook` row');
for (const text of [entry!.replacement, entry!.reason]) expect(text).not.toMatch(/materiali[sz]/);
});

it('the second carrier, the /meta door and `stack.connectors[]` refuse all three — with controls', () => {
const door = getMetadataTypeSchema('connector');
expect(door, 'no schema bound for `connector`').toBeDefined();
Expand Down
Loading
Loading