diff --git a/.changeset/20287-connector-triggers-retired.md b/.changeset/20287-connector-triggers-retired.md new file mode 100644 index 00000000000..5527cfd07e3 --- /dev/null +++ b/.changeset/20287-connector-triggers-retired.md @@ -0,0 +1,79 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: retire the connector `triggers` array — the `ConnectorTrigger` shape nothing ever registered, polled or received (#20287) + +**BREAKING** — `connector.triggers` (the `ConnectorTrigger` array: `key`, `label`, +`description`, `type: 'polling' | 'webhook'`, `intervalSeconds`) is removed from +`ConnectorSchema` and `DeclarativeConnectorEntrySchema` — so from `defineConnector`, +`stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` door and +`AutomationEngine.registerConnector` — and the `ConnectorTriggerSchema` / +`ConnectorTrigger` exports leave `@objectstack/spec/integration` with it. ADR-0049 +enforce-or-remove, ruled RETIRE on the maintainer's criterion for a +declared-but-unenforced family; ADR-0041 is unchanged: connector-event triggers stay +in its third tier, as their own trigger package, promoted when real projects ask for +them — and then in the mainstream shape (subscribe / unsubscribe lifecycle, +signature verification, a dedupe cursor), which these five keys could not carry. + +Measured before removal: `registerConnector` walks a connector's `actions` only and +stores the rest of the def unread; the engine's trigger registry holds FLOW trigger +kinds (`record_change`, `time_relative`, `schedule`, `api`) and no connector trigger +ever entered it; no polling loop read `intervalSeconds`; no receiver was driven by a +`webhook` trigger; and no connector package, provider or example declared one. A +declared trigger parsed clean and never started a flow. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `connector.triggers` with a `type: 'polling'` trigger (`intervalSeconds`, or the pre-rename `interval`) | delete the key, and write a `schedule` flow whose `connector_action` node calls the connector's action — at the cadence you meant, in seconds. | +| `connector.triggers` with a `type: 'webhook'` trigger | delete the key, and write an `api` flow that the external sender calls, with a `connector_action` node calling the connector's action. It opens an inbound endpoint that never existed before: an `api` flow is refused without a per-flow secret and every call must carry its signature, so the sender must be able to sign. | +| `ConnectorTriggerSchema`, `ConnectorTrigger` | no replacement — nothing parsed or constructed a connector trigger. | + +**The one-line fix: delete `triggers:` from every connector.** +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +⚠️ Runtime behaviour is deliberately **unchanged**: no connector trigger ever started +anything. What changes is the answer an author gets — the key is refused at parse +with a prescription naming the two shapes that work, and in `tsc` (its input type is +`never`), instead of being saved with no effect. + +### The retirement kit + +- **Tombstone.** `triggers` is a `retiredKey()` tombstone on the private + `ConnectorBaseSchema` both published carriers wrap (the schema is not `.strict()`, + so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:triggers` and + `integration/DeclarativeConnectorEntry:triggers`. The key had no default, so no + retired-default residue is owed. +- **The provider-bound refusal is gone.** `DeclarativeConnectorEntrySchema` used to + refuse `triggers` on a provider-bound instance, reasoned "the provider derives them + from the upstream at boot" — untrue, since no provider ever derived a trigger. The + tombstone refuses every value on every carrier, so that rule became unreachable and + was deleted rather than re-reasoned; a provider-bound instance now meets the + retirement prescription. +- **The def leaves whole** (`RETIRED_DEFS_BY_MAJOR[18]`: `integration/ConnectorTrigger`). +- **D2 conversion `connector-triggers-removed`** (step 18, retired from the load path): + strips the array from `connectors[]` and from stored `sys_metadata` connector rows + (the rehydration seam replays it), one notice per connector, as a lossless delete. + A trigger is stripped, never turned into a flow. +- **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `triggers[].interval` to `intervalSeconds`. That trigger half is absorbed by + this removal, as its breaker half already was by the `health` removal, so with neither + half left the rename conversion is gone from the table and from step 18; an author + holding either spelling ends with no `triggers` at all. The retired-key row + `integration/ConnectorTrigger:interval` stays as the record. +- **D3 entry `connector-triggers-retired`** carries the family's judgement: which + triggers should exist now as flows, the cadence in seconds, and whether an external + sender can sign the calls a signed `api` flow requires. The absorbed rename's own D3 entry + (`connector-resilience-durations-unit-in-key`) is gone with its + conversion. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is +published, so this is breaking for consumers no telemetry was consulted for. + +Clause-②: no (narrowing) + + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 6c2aea6ead3..eb443f09f76 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1522 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 17 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 16 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 165 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1522** | 14 protocol modules | +| **Total** | **196** | **1521** | 14 protocol modules | --- @@ -187,13 +187,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 17 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 16 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorConflictResolution`, `ConnectorFieldMapping`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorTrigger`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `RetryConfig`, `SyncStrategy` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorConflictResolution`, `ConnectorFieldMapping`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `RetryConfig`, `SyncStrategy` | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 4be4d46991d..c769752e703 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -26,8 +26,8 @@ and complete lifecycle management. This protocol supports multiple authentication strategies, bidirectional sync, field mapping, and an executed retry policy. It declares no health probe, no -circuit breaker, no authored status and no webhooks of its own — see "What -this layer does NOT provide" below. +circuit breaker, no authored status, no webhooks and no triggers of its own — +see "What this layer does NOT provide" below. ## What this layer does NOT provide @@ -71,6 +71,14 @@ delivered is declared in the stack's top-level `webhooks:` collection. The "REMOVED: `health`, `status` and the nested `webhooks`" section below records the measurement. +**There are no connector triggers.** The `triggers` array (`polling` / +`webhook`) was removed in `@objectstack/spec` 17 (ADR-0049 enforce-or-remove): +nothing ever registered, polled or received one, so a declared trigger never +started a flow. Work starts from a FLOW that calls the connector's action in a +`connector_action` node — an `api` flow for an external event, a `schedule` +flow for a scheduled pull. The "REMOVED: `triggers`" section below records the +measurement. + `connectionTimeoutMs` used to be the second exception and is now **removed** (ADR-0049, the narrower second decision that surface was owed): it was carried to a provider factory but never applied as a deadline anywhere, and @@ -163,8 +171,8 @@ an exception for itself.) ## TypeScript Usage ```typescript -import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorConflictResolutionSchema, ConnectorFieldMappingSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema, SyncStrategySchema } from '@objectstack/spec/integration'; -import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorConflictResolution, ConnectorFieldMapping, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorRetryStrategy, ConnectorTrigger, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, RetryConfig, SyncStrategy } from '@objectstack/spec/integration'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorConflictResolutionSchema, ConnectorFieldMappingSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema, SyncStrategySchema } from '@objectstack/spec/integration'; +import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorConflictResolution, ConnectorFieldMapping, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorRetryStrategy, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, RetryConfig, SyncStrategy } from '@objectstack/spec/integration'; // Validate data const result = ConnectorSchema.parse(data); @@ -188,7 +196,7 @@ const result = ConnectorSchema.parse(data); | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | +| **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; apply them by hand. | | **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | | **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; apply them by hand. | @@ -282,17 +290,6 @@ const result = ConnectorSchema.parse(data); | **outputSchema** | `Record` | optional | Output schema (JSON Schema) | | **effect** | `Enum<'read' \| 'write'>` | optional | What the action does upstream: 'read' never mutates (reports acted:0); 'write' does (a successful dispatch reports acted:1). Omit when the effect is not knowable — the step is then reported as unmeasured, not as zero | -### Nested Shape: `Connector.triggers[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Trigger key | -| **label** | `string` | ✅ | Trigger label | -| **description** | `string` | optional | | -| **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **intervalSeconds** | `number` | optional | Polling interval in seconds | -| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | - ### Nested Shape: `Connector.syncConfig` | Property | Type | Required | Description | @@ -518,22 +515,6 @@ Retry strategy * `no_retry` ---- - -## ConnectorTrigger - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Trigger key | -| **label** | `string` | ✅ | Trigger label | -| **description** | `string` | optional | | -| **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **intervalSeconds** | `number` | optional | Polling interval in seconds | -| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | - - --- ## ConnectorType @@ -586,7 +567,7 @@ Connector type | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | -| **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | +| **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; apply them by hand. | | **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | | **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; apply them by hand. | @@ -680,17 +661,6 @@ Connector type | **outputSchema** | `Record` | optional | Output schema (JSON Schema) | | **effect** | `Enum<'read' \| 'write'>` | optional | What the action does upstream: 'read' never mutates (reports acted:0); 'write' does (a successful dispatch reports acted:1). Omit when the effect is not knowable — the step is then reported as unmeasured, not as zero | -### Nested Shape: `DeclarativeConnectorEntry.triggers[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Trigger key | -| **label** | `string` | ✅ | Trigger label | -| **description** | `string` | optional | | -| **type** | `Enum<'polling' \| 'webhook'>` | ✅ | Trigger type | -| **intervalSeconds** | `number` | optional | Polling interval in seconds | -| **interval** | `never` | optional | [REMOVED] `ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | - ### Nested Shape: `DeclarativeConnectorEntry.syncConfig` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md index f4415b8e6bf..7ad938eb5e4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `integration/` | 5 | +| `integration/` | 4 | diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 2cff9b8e646..246974b8adc 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -590,7 +590,7 @@ "negative": [ "an unreachable receiver must surface as failed/dead sys_http_delivery rows (and retry per the outbox schedule) — a dropped delivery with no durable trace is a FAIL", "a stored row whose triggers contain an unknown value must be dropped LOUDLY by the dispatcher (the #3196 drift-guard warn: 'dead while looking armed in Setup') — silent armed-looking deadness is a FAIL", - "connector-attached `webhooks`/`triggers` (integration/connector.zod.ts) are NOT dispatched by anything (#3197, said in-schema) — a run must not tick them as live, and a delivery appearing from one would be a spec-contract FAIL" + "connector-attached `webhooks`/`triggers` (integration/connector.zod.ts) are RETIRED `retiredKey()` tombstones (ADR-0049) — nothing ever dispatched either, and the parse now refuses both with a prescription; a connector entry carrying one must fail publish, never be accepted and ticked as live, and a delivery or flow run appearing from one would be a spec-contract FAIL" ], "traps": [ "seed-data-thin", diff --git a/packages/metadata-core/src/artifact-forward-conversion.test.ts b/packages/metadata-core/src/artifact-forward-conversion.test.ts index d700c536fb2..5e7e0f0205c 100644 --- a/packages/metadata-core/src/artifact-forward-conversion.test.ts +++ b/packages/metadata-core/src/artifact-forward-conversion.test.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect } from 'vitest'; -import { ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; +import { ALL_CONVERSIONS, ObjectStackDefinitionSchema, applyConversions, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { applyArtifactForwardConversions, parseRangeFloor, @@ -38,6 +38,53 @@ type _MirrorToSpec = ArtifactConversionNotice extends ConversionNotice ? true : const _mirrorPin: [_SpecToMirror, _MirrorToSpec] = [true, true]; void _mirrorPin; +// ── The per-entry rule, read off the LIVE registry ────────────────────────── +// Several pins below model a release (`17.4.0`, `17.5.0`) against THIS tree's +// real registry, and a registry grows retirements stamped past any release a +// pin names: a retirement landing after a version bump is stamped with the new +// label (spec's `retired-after.census.test.ts`). So a pin that hard-codes which +// entries a floor opens is a snapshot of the registry, not the rule. These +// helpers state the rule itself — entry E opens for a floor at or above the +// label when `floor <= E.retiredAfter` — with the door's two default flips +// named, as the blocks below already name them. + +/** The door's DEFAULT-FLIP refusals, named in their own blocks below (#17885, #15429). */ +const DOOR_DEFAULT_FLIPS: readonly string[] = ['app-hidden-to-unpublished', 'flow-decision-mode-inclusive-explicit']; + +const versionTriple = (v: string): [number, number, number] => + v.split(/[.-]/).slice(0, 3).map((n) => Number.parseInt(n, 10)) as [number, number, number]; + +function compareVersions(a: string, b: string): number { + const x = versionTriple(a); + const y = versionTriple(b); + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1; + return 0; +} + +/** Every retired entry the per-entry half opens for `floor` (at or above the label): id → `retiredAfter`. */ +function openedByRule(floor: string): Map { + const opened = new Map(); + for (const c of ALL_CONVERSIONS) { + if (c.retiredFromLoadPath !== true || DOOR_DEFAULT_FLIPS.includes(c.id)) continue; + if (compareVersions(floor, c.retiredAfter) <= 0) opened.set(c.id, c.retiredAfter); + } + return opened; +} + +/** + * The first `x.y.z` past both `label` and every retired entry's `retiredAfter`: + * the floor of an artifact authored against the surface a runtime at `label` + * enforces, which no window opens for. The derivation + * `packages/metadata/src/plugin-unbound-form-predicate-roots.test.ts` uses for + * its "current surface", over an injected label. + */ +function currentSurfaceFloor(label: string): string { + const all = [label, ...ALL_CONVERSIONS.flatMap((c) => (c.retiredFromLoadPath === true ? [c.retiredAfter] : []))]; + const sorted = [...all].sort(compareVersions); + const [major, minor, patch] = versionTriple(sorted[sorted.length - 1]!); + return `${major}.${minor}.${patch + 1}`; +} + /** The measured 17.1-built shape: full CRUD plus the two retired lifecycle bits. */ function legacyPermissionDefinition(protocolRange: string | undefined) { return { @@ -98,21 +145,51 @@ describe('applyArtifactForwardConversions — the versioned window (#12772)', () }); it('REFUSES the amnesty for an artifact authored at the current spec version — no blanket strip', () => { - // "Current" for THIS registry: every retirement it carries is stamped - // `retiredAfter` 17.4.0 or earlier, so a 17.5.0 floor on a 17.5.0 runtime - // predates none of them. (A floor at the label that DOES predate one opens - // the per-entry window instead — the #20390 block below.) - const def = legacyPermissionDefinition('^17.5.0'); - const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); + // "Current" is DERIVED from this registry, never assumed: the first version + // past both the runtime label and every `retiredAfter` it carries, so the + // floor predates none of them. This pin used to spell it `17.5.0` beside the + // premise "every retirement is stamped 17.4.0 or earlier" — a snapshot of the + // registry that stopped holding when a retirement landed after the 17.5.0 + // version pass and was stamped `17.5.0`. (A floor at the label that DOES + // predate one opens the per-entry window instead — the next case and the + // #20390 block below.) + const current = currentSurfaceFloor('17.5.0'); + const def = legacyPermissionDefinition(`^${current}`); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: current }); expect(result.verdict).toBe('authored-current'); expect(result.notices).toEqual([]); + expect(result.replayedRetirements).toEqual([]); // The definition comes back by reference, retired keys still present — // the strict parse downstream is what answers, with the tombstone. expect(result.definition).toBe(def); expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge'); }); + it('an artifact authored at the release the label names gets no blanket strip — only retirements stamped at or after its floor open', () => { + // The label case the pin above used to model. A floor at the label is not + // "current" once the registry carries a retirement stamped at that label + // (the release still accepted the shape); the per-entry half opens for + // exactly those, and a retirement the floor post-dates — this fixture's + // `allowRestore`/`allowPurge`, retired after 17.1.0 — is never replayed. + const def = legacyPermissionDefinition('^17.5.0'); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); + const expected = openedByRule('17.5.0'); + + expect(result.verdict).toBe(expected.size > 0 ? 'converted-retired-after' : 'authored-current'); + expect(new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]))).toEqual(expected); + expect(result.replayedRetirements.map((r) => r.conversionId)).not.toContain('permission-allow-restore-purge-removed'); + for (const r of result.replayedRetirements) { + expect(compareVersions('17.5.0', r.retiredAfter), `${r.conversionId} opened below its retiredAfter`).toBeLessThanOrEqual(0); + } + // No blanket strip: nothing fired, the reference comes back, the retired + // bits are still there for the strict parse to refuse. + expect(result.notices).toEqual([]); + expect(result.definition).toBe(def); + expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowPurge'); + expect(def.permissions[0]!.objects.crm_ticket).toHaveProperty('allowRestore'); + }); + it('REFUSES the amnesty for an artifact authored at a NEWER spec than the runtime', () => { const def = legacyPermissionDefinition('^18.0.0'); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.2.0' }); @@ -504,19 +581,53 @@ describe('[#20390] the per-entry window — an artifact built by the last releas expect(replayed.get('page-assigned-profiles-removed')).toBe('17.4.0'); expect(replayed.get('dashboard-widget-chart-config-structure-removed')).toBe('17.4.0'); expect(replayed.has('flow-decision-mode-inclusive-explicit')).toBe(false); - expect([...new Set(replayed.values())]).toEqual(['17.4.0']); + // Exactly the entries the rule opens for this floor over the LIVE registry — + // every one at or after the floor, none a retirement the floor post-dates. + // This used to read `['17.4.0']` as the whole set of stamps, true only while + // no retirement had landed after the 17.5.0 version pass; one stamped + // `17.5.0` also opens for a 17.4.0 floor, and a tree labelled 17.4.0 could + // not have carried it (spec's census never stamps above the label). + expect(replayed).toEqual(openedByRule('17.4.0')); + for (const [id, retiredAfter] of replayed) { + expect(compareVersions('17.4.0', retiredAfter), `${id} opened below its retiredAfter`).toBeLessThanOrEqual(0); + } + expect([...replayed.values()]).toContain('17.4.0'); }); // Pin (3): the boundary the per-entry rule must keep. it('an artifact whose floor is exactly 17.5.0 on a 17.5.0-labelled runtime is refused, not converted', () => { + // The boundary: the 17.5.0 cohort is stamped `retiredAfter` 17.4.0, so a + // 17.5.0 floor post-dates it and its keys meet their tombstones. The window + // may still open for a retirement stamped AT 17.5.0 (one that landed after + // the version pass, whose release still accepted the shape) — that is the + // rule, read off the live registry below, and it opens nothing this + // fixture carries. const def = builtBy174('^17.5.0'); const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.5.0' }); + const expected = openedByRule('17.5.0'); + + expect(result.verdict).toBe(expected.size > 0 ? 'converted-retired-after' : 'authored-current'); + expect(new Map(result.replayedRetirements.map((r) => [r.conversionId, r.retiredAfter]))).toEqual(expected); + for (const id of ['page-assigned-profiles-removed', 'dashboard-widget-chart-config-structure-removed']) { + expect(result.replayedRetirements.map((r) => r.conversionId), `${id} is post-dated by the floor`).not.toContain(id); + } + expect(result.notices).toEqual([]); + expect(result.definition).toBe(def); + // The strict parse the door feeds refuses every retired site, tombstones included. + expect(issuePaths(result.definition)).toEqual(RETIRED_SITES); + }); + + it('an artifact authored against the current surface (past the label and every retiredAfter) is refused with the window shut', () => { + // Pin (3)'s verdict half, kept at full strength where the registry cannot + // move it: a floor past every stamp opens nothing at all. + const current = currentSurfaceFloor('17.5.0'); + const def = builtBy174(`^${current}`); + const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: current }); expect(result.verdict).toBe('authored-current'); expect(result.notices).toEqual([]); expect(result.replayedRetirements).toEqual([]); expect(result.definition).toBe(def); - // The strict parse the door feeds refuses every retired site, tombstones included. expect(issuePaths(result.definition)).toEqual(RETIRED_SITES); }); diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index 4d043368c97..73d14c3a235 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -36,8 +36,6 @@ "ConnectorRetryStrategySchema (const)", "ConnectorSchema (const)", "ConnectorState (type)", - "ConnectorTrigger (type)", - "ConnectorTriggerSchema (const)", "ConnectorType (type)", "ConnectorTypeSchema (const)", "ConnectorUpstreamUnavailableError (class)", diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 8d7889b1581..7f5720f553b 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -29,7 +29,7 @@ "integration/Connector:retryConfig", "integration/Connector:status [RETIRED]", "integration/Connector:syncConfig", - "integration/Connector:triggers", + "integration/Connector:triggers [RETIRED]", "integration/Connector:type", "integration/Connector:webhooks [RETIRED]", "integration/ConnectorAction:description", @@ -55,12 +55,6 @@ "integration/ConnectorInstanceBearerAuth:credentialRef", "integration/ConnectorInstanceBearerAuth:type", "integration/ConnectorInstanceNoAuth:type", - "integration/ConnectorTrigger:description", - "integration/ConnectorTrigger:interval [RETIRED]", - "integration/ConnectorTrigger:intervalSeconds", - "integration/ConnectorTrigger:key", - "integration/ConnectorTrigger:label", - "integration/ConnectorTrigger:type", "integration/DataSyncConfig:batchSize", "integration/DataSyncConfig:conflictResolution", "integration/DataSyncConfig:deleteMode", @@ -96,7 +90,7 @@ "integration/DeclarativeConnectorEntry:retryConfig", "integration/DeclarativeConnectorEntry:status [RETIRED]", "integration/DeclarativeConnectorEntry:syncConfig", - "integration/DeclarativeConnectorEntry:triggers", + "integration/DeclarativeConnectorEntry:triggers [RETIRED]", "integration/DeclarativeConnectorEntry:type", "integration/DeclarativeConnectorEntry:webhooks [RETIRED]", "integration/RetryConfig:backoffMultiplier", diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index 3cfad5fd15f..509bb615a5c 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -24,8 +24,6 @@ "ConnectorRetryStrategy": "integration/ConnectorRetryStrategy", "ConnectorRetryStrategySchema": "integration/ConnectorRetryStrategy", "ConnectorSchema": "integration/Connector", - "ConnectorTrigger": "integration/ConnectorTrigger", - "ConnectorTriggerSchema": "integration/ConnectorTrigger", "ConnectorType": "integration/ConnectorType", "ConnectorTypeSchema": "integration/ConnectorType", "DataSyncConfig": "integration/DataSyncConfig", diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index 5f7f34c7868..168da5cb9da 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -36,8 +36,6 @@ "ConnectorRetryStrategySchema": "src/integration/connector.zod.ts#ConnectorRetryStrategySchema (const)", "ConnectorSchema": "src/integration/connector.zod.ts#ConnectorSchema (const)", "ConnectorState": "src/integration/connector-descriptor.ts#ConnectorState (type)", - "ConnectorTrigger": "src/integration/connector.zod.ts#ConnectorTrigger (type)", - "ConnectorTriggerSchema": "src/integration/connector.zod.ts#ConnectorTriggerSchema (const)", "ConnectorType": "src/integration/connector.zod.ts#ConnectorType (type)", "ConnectorTypeSchema": "src/integration/connector.zod.ts#ConnectorTypeSchema (const)", "ConnectorUpstreamUnavailableError": "src/integration/connector-provider-errors.ts#ConnectorUpstreamUnavailableError (class)", diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index db60044df8f..e75c3998b90 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -13,7 +13,6 @@ "integration/ConnectorInstanceBearerAuth", "integration/ConnectorInstanceNoAuth", "integration/ConnectorRetryStrategy", - "integration/ConnectorTrigger", "integration/ConnectorType", "integration/DataSyncConfig", "integration/DeclarativeConnectorEntry", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 60174dc6c3b..3e72626a6cd 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -944,7 +944,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | rest_api | seeded 2026-09-21 (#14640) — the FIFTH `RestServerConfig` sub-object, enrolled a round after the four above and deliberately so. #14369 left `RestApiConfigSchema` out because the `api` block's consumption seam was then still VALIDATE-ONLY (#11637 ran the declared contract and discarded its output), so a census would have recorded a half that was about to move; the gate source and four rows of this table said as much. That fence was re-tested before a line of this ledger was written and it has EXPIRED: `RestServer.normalizeConfig` now BUILDS the `api` block from `parseDeclaredApiConfig`'s output — “the asymmetry is gone and all five now build from their parsed output” — and the change is RELEASED, not in flight, with `packages/rest/CHANGELOG.md` re-stating the same zero this file records. ⛔ **The ledger is `rest_api.json`, NOT `api.json`**: that name was already taken by `ApiEndpointSchema`, the registered `api` metadata type with real consumers in the matcher, executor, policy chain and mapping layer — one spelling, two unrelated meanings inside `packages/spec`, and filing here would have published one file's measurement under the other's name. Live 20 = `version` / `basePath` / `apiPath`, which `getApiBasePath` splices into the prefix of EVERY mounted route (read through a whole-block destructure, which is why the dead-key census below had to sweep destructuring shapes and not a property-access pattern alone), the eight `enable*` switches, each gating a mount and most of them also the discovery document's capability block, and `projectResolution` — plus, since #20294 (ENFORCED 2026-09-28, ruling B on #20359), the eight identity members of `documentation` (`title`, `description`, `termsOfService`, `contact.name` / `url` / `email`, `license.name` / `url`), which `RestServer.overlayDocumentationInfo` lays over the served OpenAPI `info` on both `/openapi.json` doors (`contact` / `license` replaced whole; nothing authored serves the artifact's `info` unchanged). Dead 4 = the `requireAuth` tombstone (#3963, still `.omit()`ed by this seam because #3963 chose warn-and-ignore and converting that to a boot failure is that decision's to make), the `responseFormat` and `documentation.enabled` tombstones (RETIRED 2026-09-27, #20295, ADR-0049 enforce-or-remove — refused at `RestServer` construction with their prescription; `responseFormat` retired whole, so its three child rows collapsed into one), and the `documentation.version` tombstone (RETIRED 2026-09-28, #20294 — the served `info.version` is the protocol version, #11646; an app's own release number goes into `description`). Until #20294 the nine non-`enabled` members of `documentation` (drilled, including its nested `contact` / `license`) were all dead too — normalized into `this.config.api` and read back by nothing, so `documentation.title` retitled no served document. Every zero carries a lit control on the same instrument (twelve sibling keys on the same block return 1-2 reads), each of the three shapes a spelling sweep is blind to was swept with its own control, and the backstop is structural rather than textual: `NormalizedRestServerConfig` is module-local with no `export` and `RestServer.config` is `private`, so the normalized block cannot be reached from outside that one class. ⛔ **The two dead containers do NOT share one verdict**: `documentation`'s members are OpenAPI `info` fields whose enforce route collides with a recorded ownership decision (`info` is written by `build-openapi.ts` and was passed through untouched by #11646 — settled by ruling B on #20359, which split the block by field owner: identity overlaid, `version` retired), while `responseFormat`'s enforce route means making the response envelope configurable — a larger claim. This file records status; the enforce-or-remove call per key is a follow-up on the human floor — made for `responseFormat` and `documentation.enabled` (retired, #20295), and for `documentation`'s other members (#20294: the identity members enforced, `version` retired). `evidenceScope` stays `in-repo`: objectui was measured clean at the pinned sha and at head against a lit control, but the closed cloud runtime was not reachable from the measuring container, so #14796's structural reading is cited as a standing reading rather than re-claimed as a sweep | | realtime_subscription | seeded 2026-09-04 (#14446) — a TRANSPORT-PROTOCOL surface, the fifth category the `SPEC_ONLY_SCHEMAS` override has had to reach. `SubscriptionSchema` (`packages/spec/src/api/realtime.zod.ts`) is what a client declares to open a realtime subscription: the item type of `RealtimeConfigSchema.subscriptions` and the `Subscription` the generated API reference publishes. Like `query` it is a request surface rather than stored metadata, and like `query` that is exactly why it went unasked — no registry holds it, `RealtimeConfigSchema` is `.passthrough()` so nothing downstream even refuses an unknown key, and the whole vocabulary sat outside the denominator while the reference kept publishing it. Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema` for the reason the four `RestServerConfig` sub-objects document one row up: the walk drilled exactly ONE level when this was rooted (it recurses as of #17424; the rooting stands), so with the config as the root `events[].type` and `events[].filters` would inherit a container verdict instead of carrying rows of their own — #4956's shape. **Dead 6 = every key it has, and the CONTAINER is the finding**: nothing outside `packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or `RealtimeConfigSchema` at all, so no key beneath them can be read (the `manifest.contributes` reasoning). The two keys the card measured are the sharp ones. `events[].type` accepts `RealtimeEventType`, whose four members (`record.created` / `record.updated` / `record.deleted` / `field.changed`), as measured at `5f5511f0` before #20288 repointed the enum at the emitted `DataEventType` + `BulkDataEventType` names, are DISJOINT from what the engine publishes (`DataEventType`'s `data.record.*`, live emitter in `service-knowledge`), so an author who writes the enum's own `record.created` gets a subscription that silently never fires — and the enum is what the API reference shows them. Its direction is settled by the 2026-09-02 triage and quoted verbatim in the row: enforce means REPOINTING THE ENUM, never changing what the runtime publishes. `field.changed` is the same spelling the sibling `DataEventType` REMOVED in 17.0.0 (#4673, PR #4685) for having no producer; it survives here only because this enum was never in a ratchet's denominator. `events[].filters` is `z.unknown().optional()` — the textbook ADR-0049 fourth state, no shape and no reader, failing in the permissive direction (a subscriber who filters receives every event). ⚠️ Three spellings of a realtime subscription exist and only the third is executed: this one, `websocket.zod.ts#EventSubscriptionSchema`, and the plain interface `contracts/realtime-service.ts#RealtimeSubscriptionOptions` that `in-memory-realtime-adapter.ts#matchesSubscription` actually reads. The file note names the same-name-different-shape traps so the next census does not mistake one for a consumer. Zero live | | sharing_rule | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, and the first one PAID (`connector` and `analytics_cube` are still owed on that card). Not a registered kind: it is bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reaches the walk through `getMetadataTypeSchema`'s unregistered-kind fallback, so this ledger governs a type `listMetadataTypeSchemaTypes()` still does not enumerate. One shape fact decides every row: the AUTHORING shape is not the ENFORCED shape. ADR-0057 D6 makes the `sys_sharing_rule` row canonical (`object_name` + `criteria_json` + `recipient_type`/`recipient_id` + `access_level`) and `bootstrapDeclaredSharingRules` translates each authored key into it at boot — nothing re-parses `SharingRuleSchema` at enforcement time — so every consumer cited reads a COLUMN and every row carries the `producer` (#4837) that populates it, which is the `seed.env` lesson applied to a whole type rather than to one key. Preview read points ENUMERATED per the #7131 rule and the answer recorded rather than skipped: `registerBuiltinPreviews()` (objectui @dda8f381) registers twenty types and `sharing_rule` is not one of them; what objectui does consume is the whole shape, on the CREATE door only (`AUTHOR_SHAPE_ONLY_TYPES` — the EDIT door is deliberately ungated because a served body carries the `_diagnostics` decoration this `.strict()` schema rejects). The single non-`live` row is `type`, the `SharingRuleType` discriminator: one member, `criteria`, whose only reader is a defensive `=== 'owner'` comparison that is unreachable for every value the schema admits. `planned` on the `action.operation` precedent (a one-member discriminator held `planned` until a runtime half dispatched on it, #15080), and deliberately NOT an enforce-or-remove candidate: the key is required, so removing it would break every authored rule to delete nothing. | -| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | +| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the two rows where they are the whole verdict (`authentication` and `actions`; `triggers` was the third until its retirement made it a tombstone both carriers refuse). **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/25 split (live/planned/dead; counts read from the generated `state-counts/connector.md` shard, never hand-kept here): the ledger's denominator entry exists for the AUTHORING doors (`defineStack({ connectors })`, `PUT /meta/connector/:name`), while the same `ConnectorSchema` is what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code — so a key can have a real consumer and still do nothing when a metadata author writes it. The keys an authored entry can reach are exactly the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields (the former "plus `name`" tail double-counted it), `loadPackageFile` is host-injected rather than authored, and `provider` selects the factory without ever reaching the context; `type` and `icon` reach that context and are dropped by all three shipped factories, and each says so on its own row. `authentication` is the ledger's `planned`, and ⛔ NOT "refused outright" — the former tail here said exactly that and all three instruments contradict it, including the one it cites: the KEY is ACCEPTED (`connector.zod.ts` declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, and the accepted value does nothing); what #7990 refuses is a non-`none` VALUE (`if (entry.authentication && entry.authentication.type !== 'none')`, whose own message prescribes "drop `authentication` (or set `{ type: 'none' }`)"); and ADR-0097 §3, titled "Credentials are references", rejects **inline secrets** in stack metadata, not the key. Accepted-and-ignored, plus a loud refusal of every value but `{ type: 'none' }`, is exactly the basis of the `planned` verdict — which the row itself already stated ("the accepted value does nothing"), so the summary, not the row, was the wrong half. The 25 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `metadata`, `actions.description`/`.outputSchema`, and the seven top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks` and `triggers`. That sums to 25, the dead count the generated `state-counts/connector.md` shard carries. ⚠️ It was 30 until the connector `triggers` array was retired (ADR-0049; ADR-0041 unchanged): `triggers` counted 6 drilled rows (`key`, `label`, `description`, `type`, `intervalSeconds` and the `interval` rename tombstone — dead because nothing read a connector trigger, which the schema's own docblock said: #3197) and is now ONE leaf tombstone row, by the same gate rule as `health` below. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `triggers` and `fieldMappings.transform` — but ⛔ that eight is NOT a separate addend: the first seven ARE the top-level tombstones counted above and the last one is already inside the `fieldMappings` count, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`triggers.interval` was one of the eight until its array left whole with `triggers`, whose own leaf row took its place — the count held at eight by a swap, not by standing still.) (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | | analytics_cube | seeded 2026-09-17 (#18582) — the third debt, paid in the same diff as `connector`. Not a registered kind either: bound in `UNREGISTERED_KIND_SCHEMAS` by #10194 and reached through the same unregistered-kind fallback. **ONE Cube shape, THREE producers, one registry** is what decides every row: `cube-registry.ts` names them itself — authored cubes (`analyticsCubes[]` / `defineCube()`, threaded by the CLI into `AnalyticsServiceConfig.cubes`), COMPILED DATASETS (ADR-0021, where `dataset-compiler` mints a Cube), and ad-hoc query inference. Only the first is the authoring door governed here, so a key whose only reader sits on the compiled-dataset path is not live for an authored cube however busy that reader is — the #4837 producer rule on a shape with three producers. That is `dimensions.granularities` (read by `dataset-executor#granularityOf`, whose argument is a `CompiledDataset` an authored cube never becomes) and `measures.format` (written by the compiler, threaded to the wire from the DATASET measure instead). The query path is genuinely live: `sql` is the FROM table AND the object whose RLS read scope is injected, `measures.type` picks the aggregate, `measures.sql`/`dimensions.sql` the column, `joins[].name` the joined table. The 9 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the three `description`s, `measures.format`, `dimensions.granularities`, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity — RETIRED by #20300 (ADR-0049 enforce-or-remove) as `retiredKey()` tombstones on the member `strictObject`s, so those two rows STAY `dead` (the tombstone keeps the key in the walked shape) and the count does not move. **#20282** flips the tenth, the visibility flag `public`, `dead` → `live` 2026-09-27: seeded as a knob that was never wired (three internal mints wrote `false`, nothing read it), it is now read by `service-analytics`' `cube-visibility.ts#isCubePublic` — `getMeta` omits a hidden cube and `query()` / `generateSql()` refuse it — in the same change that moved its default from `false` to the Cube.dev `true`, since enforcing the old default would have hidden every authored cube. It was 12 until #18612 RETIRED `joins[].relationship` and the REQUIRED `joins[].sql` (ADR-0049 enforce-or-remove, maintainer-ruled batch #154): the ON clause is SYNTHESISED as an FK equality and the authored one was never consulted, so a declared join condition came back REPLACED under a 200. `CubeJoinSchema` is a `strictObject`, so the route was strict deletion plus a `guidance` prescription and the two rows left this ledger with the keys — not the `retiredKey()` route, which keeps the row. **#10238 is not prejudged**: whether cube authoring is live end to end is still its own measurement — this ledger answers the per-key question only | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every diff --git a/packages/spec/liveness/connector.json b/packages/spec/liveness/connector.json index 4c0f83d58fa..ca81696ee82 100644 --- a/packages/spec/liveness/connector.json +++ b/packages/spec/liveness/connector.json @@ -1,6 +1,6 @@ { "type": "connector", - "_note": "DeclarativeConnectorEntrySchema (packages/spec/src/integration/connector.zod.ts). Seeded 2026-09-17 (#18582) together with `analytics_cube`: the last two of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator to `authorableTypes()` (`sharing_rule` was paid first, PR #18587). Their landing empties that map. NOT a registered metadata KIND — bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. WHAT THE WALKER ACTUALLY RESOLVES, measured rather than assumed: the binding names `DeclarativeConnectorEntrySchema`. ⚠️ THE MECHANISM CHANGED WITH THE `connectionTimeoutMs` RETIREMENT and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child. The CONCLUSION is unchanged and re-measured on the built entry: the pipe keeps a read-through `shape`, both carriers expose 30 keys, and the key sets are byte-identical with no entry-only and no base-only key — the ADR-0097 cross-field rules add no key and remove none. The gate therefore cannot tell the two schemas apart; what the binding buys is REFUSALS, which are invisible to the walk and visible only in the `authentication` / `actions` / `triggers` rows below, where they are the whole verdict. THE SHAPE FACT THAT DECIDES EVERY ROW: one schema, TWO doors. This ledger's denominator entry exists because of the AUTHORING doors (`defineStack({ connectors })` and `PUT /api/v1/meta/connector/:name`); the same `ConnectorSchema` is ALSO what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code. So a key can have a real consumer and still do nothing when a metadata author writes it, and every row below says WHICH door its consumer is fed from. The keys an authored entry can reach are exactly the AUTHOR-SUPPLIED `ConnectorProviderContext` fields plus `provider` and `enabled`, and the three corrections in that sentence are each measured: `name` is itself one of those fields (`connector-provider.ts#ConnectorProviderContext` declares it), so the former 'plus `name`' tail DOUBLE-COUNTED it; `loadPackageFile` is HOST-INJECTED rather than authored (the materializer passes `createPackageFileLoader(this.options.packageRoot)`, and no authored key reaches it), so the bare 'the `ConnectorProviderContext` fields' OVER-INCLUDED it; and `provider` is read on the AUTHORING door itself — it gates the desired set (`typeof entry.provider !== 'string' … continue`) and selects the factory (`engine.getConnectorProvider(provider)`) — without ever reaching the context, so it was LEFT OUT of the 'exactly'. Everything else in an authored entry is stored and served back by `/meta/connector` WITHOUT REACHING A PROVIDER FACTORY. ⛔ That is NOT the same claim as 'read by no runtime', which the former tail here asserted and which this file's own `actions.key` row already contradicted: `packages/services/service-automation/src/plugin.ts#findInertDeclaredConnectors` reads `(c.actions?.length ?? 0) > 0` on EVERY descriptor at boot and `auditDeclaredConnectors` turns it into the #2612 inert-descriptor warning, so `actions` is a real runtime read of a key no factory is handed. Measured rather than asserted, and this is the whole census: the only reads of a declared entry anywhere in the materializer are `name`, `provider`, `enabled`, `label`, `description`, `icon`, `type`, `providerConfig`, `auth`, `retryConfig` and `requestTimeoutMs` (ALL BUT `name` AND `enabled` are also exactly `connectorInstanceSignature`'s fingerprint, and the former 'the last nine' tail was wrong in BOTH directions: `plugin.ts#connectorInstanceSignature` declares NINE parameter keys and hashes the same nine — `provider`, `providerConfig`, `auth`, `label`, `description`, `icon`, `type`, `retryConfig`, `requestTimeoutMs` — so the old tail swept in `enabled`, which is never hashed, and dropped `provider`, which is. Measured on both halves of that function, its parameter type and its hashed object, which agree key for key. The fingerprint is why `metadata`, `status`, `syncConfig`, `health`, `triggers` and `webhooks` keep their `read by nothing` rows) plus that one `actions` read. Reaching no factory and being read by nothing are two different claims; only the first holds of the whole remainder. That asymmetry is the trap this type carries, and it is recorded per key rather than asserted once. PRIOR MEASUREMENTS RE-VERIFIED, not inherited, and each named by its REAL id: the ADR-0087 conversion registry entry is `field-mapping-transform-removed`, with NO `connector-` prefix — the `connector-`-prefixed neighbour is `connector-rate-limit-config-removed`, a different retirement, and the former tail here fused the two names. That entry records 'Execution: none. `fieldMappings` is spelled only inside `packages/spec` — the four connector packages, the automation engine, REST and objectui never read it, and nothing anywhere switches on `transform.type`' (2026-08-06). Re-run at this head it SPLITS, and the split is recorded rather than smoothed: the gloss holds — no read in the connector packages, the engine, REST or objectui — but the literal 'spelled only inside `packages/spec`' is FALSE, `fieldMappings` occurring on 14 lines over 6 files outside `packages/spec`, every one of them prose (one changeset, two `content/docs` pages, ADR-0097, the protocol upgrade guide, one upgrade skill). Quoting that clause WITHOUT its gloss is what makes it falsifiable, which is the same trap the `retryConfig` census sentence in this type's README row records. The other two still hold outright: the `syncConfig.schedule` retirement recorded '`syncConfig` has no reader outside `packages/spec`' (#16320, 2026-09-10), and at this head the only non-`packages/spec` hit in code is a pair of COMMENT lines in `packages/qa/dogfood/test/expression-conformance.ledger.ts`, not a read; and `ConnectorTriggerSchema`'s own docblock still says 'NOT YET ENFORCED — declared but never read by the runtime (#3197)' verbatim. WHAT THE CONVERSION REGISTRY ACTUALLY SAYS TODAY, quoted rather than paraphrased, because the former tail here INVERTED it: the comment inside `connector-rate-limit-config-removed`'s fixture reads '`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing.' It asserts they are NOT live and it scopes 'untouched' to its own conversion; the former tail quoted it as asserting 'they are live' and then answered 'They are not read anywhere', which was wrong twice — it inverted the source, and 'not read anywhere' is contradicted both by the materializer handing BOTH `retryConfig` and `requestTimeoutMs` to `ConnectorProviderContext` and by this very line's census, which lists both among the eleven. Measured direction: the comment was TRUE when this ledger was seeded and is STALE now — those three rows read `live` on all eight `retryConfig` sub-keys, `live` on `requestTimeoutMs`, and RETIRED on `connectionTimeoutMs`. ⛔ The stale comment is NOT rewritten from here: it is #19729's, as a dated note beside it, and it is not a line this PR's diff touches. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` (packages/app-shell/src/views/metadata-admin/previews/index.ts) makes twenty-two unconditional `registerMetadataPreview(` calls naming twenty-two DISTINCT metadata types, which share twenty distinct preview components (`PermissionPreview` serves `permission` and `profile`, `PositionPreview` serves `position` and `role`) — the READING is stated because those two numbers differ and a bare count is unfalsifiable; the former 'nineteen' here matched neither. `connector` is NOT among those twenty-two type names — this type has no registered metadata-admin preview. What objectui DOES consume is (a) the whole SHAPE, via `clientValidation.ts`, which maps `connector` to `DeclarativeConnectorEntrySchema` on BOTH the create and the edit door (it is not strict, so it may judge a stored body), and (b) the RUNTIME registry projection `GET /api/v1/automation/connectors`, from which `connectorsToOptions` reads `name`/`label`/`origin`, `connectorActionsToOptions` reads `actions[].key`/`.label`, and `connectorActionInputSchema` reads `actions[].inputSchema`. Those three are the cross-repo citations below. ADR-0054: no row carries a `proof` and none is owed — no high-risk class binds a `connector/*` path.", + "_note": "DeclarativeConnectorEntrySchema (packages/spec/src/integration/connector.zod.ts). Seeded 2026-09-17 (#18582) together with `analytics_cube`: the last two of the three PENDING_GOVERNANCE debts #18133 declared when PR #18581 widened the governance denominator to `authorableTypes()` (`sharing_rule` was paid first, PR #18587). Their landing empties that map. NOT a registered metadata KIND — bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. WHAT THE WALKER ACTUALLY RESOLVES, measured rather than assumed: the binding names `DeclarativeConnectorEntrySchema`. ⚠️ THE MECHANISM CHANGED WITH THE `connectionTimeoutMs` RETIREMENT and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child. The CONCLUSION is unchanged and re-measured on the built entry: the pipe keeps a read-through `shape`, both carriers expose 30 keys, and the key sets are byte-identical with no entry-only and no base-only key — the ADR-0097 cross-field rules add no key and remove none. The gate therefore cannot tell the two schemas apart; what the binding buys is REFUSALS, which are invisible to the walk and visible only in the `authentication` / `actions` rows below, where they are the whole verdict. (`triggers` was the third such row until its retirement: the key is now a `retiredKey()` tombstone on the shared base, refused on BOTH carriers, and the entry-only provider-bound refusal it replaced is gone — see that row.) THE SHAPE FACT THAT DECIDES EVERY ROW: one schema, TWO doors. This ledger's denominator entry exists because of the AUTHORING doors (`defineStack({ connectors })` and `PUT /api/v1/meta/connector/:name`); the same `ConnectorSchema` is ALSO what `AutomationEngine.registerConnector` parses for a def a PLUGIN or an ADR-0097 provider factory builds in code. So a key can have a real consumer and still do nothing when a metadata author writes it, and every row below says WHICH door its consumer is fed from. The keys an authored entry can reach are exactly the AUTHOR-SUPPLIED `ConnectorProviderContext` fields plus `provider` and `enabled`, and the three corrections in that sentence are each measured: `name` is itself one of those fields (`connector-provider.ts#ConnectorProviderContext` declares it), so the former 'plus `name`' tail DOUBLE-COUNTED it; `loadPackageFile` is HOST-INJECTED rather than authored (the materializer passes `createPackageFileLoader(this.options.packageRoot)`, and no authored key reaches it), so the bare 'the `ConnectorProviderContext` fields' OVER-INCLUDED it; and `provider` is read on the AUTHORING door itself — it gates the desired set (`typeof entry.provider !== 'string' … continue`) and selects the factory (`engine.getConnectorProvider(provider)`) — without ever reaching the context, so it was LEFT OUT of the 'exactly'. Everything else in an authored entry is stored and served back by `/meta/connector` WITHOUT REACHING A PROVIDER FACTORY. ⛔ That is NOT the same claim as 'read by no runtime', which the former tail here asserted and which this file's own `actions.key` row already contradicted: `packages/services/service-automation/src/plugin.ts#findInertDeclaredConnectors` reads `(c.actions?.length ?? 0) > 0` on EVERY descriptor at boot and `auditDeclaredConnectors` turns it into the #2612 inert-descriptor warning, so `actions` is a real runtime read of a key no factory is handed. Measured rather than asserted, and this is the whole census: the only reads of a declared entry anywhere in the materializer are `name`, `provider`, `enabled`, `label`, `description`, `icon`, `type`, `providerConfig`, `auth`, `retryConfig` and `requestTimeoutMs` (ALL BUT `name` AND `enabled` are also exactly `connectorInstanceSignature`'s fingerprint, and the former 'the last nine' tail was wrong in BOTH directions: `plugin.ts#connectorInstanceSignature` declares NINE parameter keys and hashes the same nine — `provider`, `providerConfig`, `auth`, `label`, `description`, `icon`, `type`, `retryConfig`, `requestTimeoutMs` — so the old tail swept in `enabled`, which is never hashed, and dropped `provider`, which is. Measured on both halves of that function, its parameter type and its hashed object, which agree key for key. The fingerprint is why `metadata`, `status`, `syncConfig`, `health`, `triggers` and `webhooks` keep their `read by nothing` rows) plus that one `actions` read. Reaching no factory and being read by nothing are two different claims; only the first holds of the whole remainder. That asymmetry is the trap this type carries, and it is recorded per key rather than asserted once. PRIOR MEASUREMENTS RE-VERIFIED, not inherited, and each named by its REAL id: the ADR-0087 conversion registry entry is `field-mapping-transform-removed`, with NO `connector-` prefix — the `connector-`-prefixed neighbour is `connector-rate-limit-config-removed`, a different retirement, and the former tail here fused the two names. That entry records 'Execution: none. `fieldMappings` is spelled only inside `packages/spec` — the four connector packages, the automation engine, REST and objectui never read it, and nothing anywhere switches on `transform.type`' (2026-08-06). Re-run at this head it SPLITS, and the split is recorded rather than smoothed: the gloss holds — no read in the connector packages, the engine, REST or objectui — but the literal 'spelled only inside `packages/spec`' is FALSE, `fieldMappings` occurring on 14 lines over 6 files outside `packages/spec`, every one of them prose (one changeset, two `content/docs` pages, ADR-0097, the protocol upgrade guide, one upgrade skill). Quoting that clause WITHOUT its gloss is what makes it falsifiable, which is the same trap the `retryConfig` census sentence in this type's README row records. The other two still hold outright: the `syncConfig.schedule` retirement recorded '`syncConfig` has no reader outside `packages/spec`' (#16320, 2026-09-10), and at this head the only non-`packages/spec` hit in code is a pair of COMMENT lines in `packages/qa/dogfood/test/expression-conformance.ledger.ts`, not a read; and `ConnectorTriggerSchema`'s own docblock said 'NOT YET ENFORCED — declared but never read by the runtime (#3197)' verbatim — the reading the `triggers` retirement then acted on (2026-09-29), taking the schema whole; see the `triggers` row. WHAT THE CONVERSION REGISTRY ACTUALLY SAYS TODAY, quoted rather than paraphrased, because the former tail here INVERTED it: the comment inside `connector-rate-limit-config-removed`'s fixture reads '`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing.' It asserts they are NOT live and it scopes 'untouched' to its own conversion; the former tail quoted it as asserting 'they are live' and then answered 'They are not read anywhere', which was wrong twice — it inverted the source, and 'not read anywhere' is contradicted both by the materializer handing BOTH `retryConfig` and `requestTimeoutMs` to `ConnectorProviderContext` and by this very line's census, which lists both among the eleven. Measured direction: the comment was TRUE when this ledger was seeded and is STALE now — those three rows read `live` on all eight `retryConfig` sub-keys, `live` on `requestTimeoutMs`, and RETIRED on `connectionTimeoutMs`. ⛔ The stale comment is NOT rewritten from here: it is #19729's, as a dated note beside it, and it is not a line this PR's diff touches. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` (packages/app-shell/src/views/metadata-admin/previews/index.ts) makes twenty-two unconditional `registerMetadataPreview(` calls naming twenty-two DISTINCT metadata types, which share twenty distinct preview components (`PermissionPreview` serves `permission` and `profile`, `PositionPreview` serves `position` and `role`) — the READING is stated because those two numbers differ and a bare count is unfalsifiable; the former 'nineteen' here matched neither. `connector` is NOT among those twenty-two type names — this type has no registered metadata-admin preview. What objectui DOES consume is (a) the whole SHAPE, via `clientValidation.ts`, which maps `connector` to `DeclarativeConnectorEntrySchema` on BOTH the create and the edit door (it is not strict, so it may judge a stored body), and (b) the RUNTIME registry projection `GET /api/v1/automation/connectors`, from which `connectorsToOptions` reads `name`/`label`/`origin`, `connectorActionsToOptions` reads `actions[].key`/`.label`, and `connectorActionInputSchema` reads `actions[].inputSchema`. Those three are the cross-repo citations below. ADR-0054: no row carries a `proof` and none is owed — no high-risk class binds a `connector/*` path.", "props": { "name": { "status": "live", @@ -112,38 +112,9 @@ } }, "triggers": { - "children": { - "key": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "The whole trigger sub-surface is declared and unread, and the schema says so itself: `ConnectorTriggerSchema`'s docblock reads '⚠️ NOT YET ENFORCED — declared but never read by the runtime (#3197). AutomationEngine.registerConnector ignores a connector's triggers … No polling loop or webhook receiver is driven by these definitions.' Re-verified on this checkout: `registerConnector` walks `parsed.actions` only, and the `this.triggers` map in engine.ts is the FLOW trigger-type registry (`schedule`/`record`/…), a different vocabulary that no connector trigger ever enters — that near-miss is the reason this row spells the control out. The one runtime touch of the key is a REFUSAL: packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema rejects `triggers` on a provider-bound instance (ADR-0097 §5). On a descriptor it parses and evaporates. ⛔ ADR-0049 is owed a decision here rather than a sweep — either a polling/receiver engine or a retirement of the whole `ConnectorTriggerSchema` shape; the sub-keys below are dead for this one reason and are not repeated per row." - }, - "label": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `triggers.key` — nothing reads a connector trigger." - }, - "description": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `triggers.key`." - }, - "type": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `triggers.key`. `polling` and `webhook` name two engines that do not exist for connectors: no polling loop reads `intervalSeconds`, and the webhook dispatcher is driven by `sys_webhook` rows materialized from the TOP-LEVEL `webhooks:` collection (see the `webhooks` subtree below)." - }, - "intervalSeconds": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `triggers.key`. Renamed from `interval` by the protocol-18 conversion (#15680/#14478) so the unit lives in the key name — a rename that made the declaration honest without making it enforced." - }, - "interval": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "REMOVED (#15680, ruling B on #14478) — tombstoned at the schema with `retiredKey`, which carries the prescription and makes authoring it both a tsc error and a parse error; sources are renamed by the protocol-18 conversion. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Use `intervalSeconds`; the value (seconds) is unchanged. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorTriggerSchema." - } - } + "status": "dead", + "verifiedAt": "2026-09-29", + "note": "RETIRED 2026-09-29 (ADR-0049 enforce-or-remove; the connector triggers family) — the `retire` arm of the decision the prior `triggers.key` row asked for ('either a polling/receiver engine or a retirement of the whole `ConnectorTriggerSchema` shape'), ruled with ADR-0041 left as it is: connector-event triggers stay in its third tier, as their own trigger package. Tombstoned at the schema with `retiredKey` (the prescription names the two shapes that work today — an `api` flow for an external event, a `schedule` flow for a scheduled pull, each calling the connector's action in a `connector_action` node — and both interval spellings; authoring it is a tsc error and a parse error), carried by `DeclarativeConnectorEntrySchema` too, and stripped from sources and stored rows by the protocol-18 conversion `connector-triggers-removed`, never turned into flows (the D3 entry `connector-triggers-retired`). `ConnectorTrigger` left whole. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). ⚠️ It is a LEAF now: the six per-key rows that were drilled under it — `key`, `label`, `description`, `type`, `intervalSeconds` (all `dead`, verified 2026-09-17) and the `interval` rename tombstone — left with the subtree, because the gate refuses `children` on a property that is not a container (the `health` precedent). Their verdicts are carried here, one reason for all six: nothing read a connector trigger. `packages/services/service-automation/src/engine.ts#registerConnector` walks `parsed.actions` only and stores the rest of the def unread; the engine's `this.triggers` map is the FLOW trigger-kind registry (`record_change` / `time_relative` / `schedule` / `api`, a closed set), which no connector trigger ever entered; no polling loop read `intervalSeconds`; and `polling` / `webhook` named two engines that do not exist for connectors — the webhook dispatcher is driven by `sys_webhook` rows materialized from the TOP-LEVEL `webhooks:` collection. The one runtime touch was a REFUSAL on a provider-bound instance, reasoned 'the provider derives them from the upstream at boot', which was untrue (no provider derives a trigger); the tombstone made it unreachable and it was deleted rather than re-reasoned. `interval` → `intervalSeconds`, the unit rename earlier in the same unreleased step, is absorbed: its conversion left the table, and its `integration/ConnectorTrigger:interval` retired-key registration stays as the record. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "syncConfig": { "children": { diff --git a/packages/spec/liveness/state-counts/connector.md b/packages/spec/liveness/state-counts/connector.md index 7f422072040..84df8371bd3 100644 --- a/packages/spec/liveness/state-counts/connector.md +++ b/packages/spec/liveness/state-counts/connector.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `connector` | 29 | 0 | 0 | 30 | 1 | 60 | +| `connector` | 29 | 0 | 0 | 25 | 1 | 55 | diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 6de3e9d174e..2b64d6003e3 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9406,8 +9406,8 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { // Minimal by the §3 disjointness contract: the retired key and nothing // else this major's other `connectors[]` entries also walk // (`errorMapping`, `health` / `status` / `webhooks` — `health` once as - // `health.circuitBreaker.monitoringWindow` — and `triggers[].interval`), - // so every notice here is attributable to this id. + // `health.circuitBreaker.monitoringWindow` — and `triggers`, once as + // `triggers[].interval`), so every notice here is attributable to this id. { name: 'ledger_api', label: 'Ledger API', type: 'api', connectionTimeoutMs: 15000 }, // A connector that never authored the key keeps its identity — the // copy-on-write contract `stripKeys` / `mapCollection` are built on. @@ -9639,100 +9639,33 @@ const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { }, }; -/** - * The connector duration key whose name carried no unit → suffixed (protocol - * 18, #15680 for #14478): `triggers[].interval` → `intervalSeconds`. The bare - * token `interval` means MILLISECONDS elsewhere in this same spec, so the - * identical spelling carried two units a thousandfold apart. - * - * ⚠️ This entry used to carry a SECOND rename, `health.circuitBreaker. - * monitoringWindow` → `monitoringWindowMs` — the sharpest case of that card: - * `monitoringWindow` (ms) sat ONE key below `resetTimeoutMs`, which already - * spelled its unit. That half was ABSORBED by `connector-resilience-keys-removed` - * (ADR-0049 enforce-or-remove, the same unreleased protocol step): the whole - * `health` block left the schema, so a breaker key renamed here would be - * stripped by the removal immediately after — a composition with no observable - * rename — and the table's disjoint-fixture contract cannot hold a fixture - * whose `health` block another entry deletes (`spec-property-retirement` §0, - * the `agent.knowledge` precedent). An author who still holds either spelling - * is served by the removal: its notice names `health`, and the tombstone's - * prescription names both `monitoringWindow` and `monitoringWindowMs`. The id - * keeps its original spelling on purpose — ids are how the chain, the step - * list and every published changelog refer to an entry. - * - * A published connector row lands whole in `sys_metadata` (`ConnectorSchema`'s - * own docblock says so, which is why #7990 forbids inline secrets on it), so - * the chain has a seam that sees the key — hence a conversion. Retired from the - * load path, tombstoned at the schema, replayable here. - */ -const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { - id: 'connector-health-and-trigger-durations-unit-in-key', - toMajor: 18, - retiredFromLoadPath: true, - retiredAfter: '17.3.0', - surface: 'connector.triggers[].interval', - summary: "connector key 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged. The breaker half, 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs', was absorbed by the removal of the whole 'health' block)", - apply(stack, emit) { - return mapCollection(stack, 'connectors', (connector, path) => { - let next = connector; - - const triggers = next.triggers; - if (Array.isArray(triggers)) { - let triggersChanged = false; - const nextTriggers = triggers.map((trigger, i) => { - if (!isDict(trigger)) return trigger; - const renamed = renameKey(trigger, 'interval', 'intervalSeconds'); - if (!renamed) return trigger; - emit({ - from: 'interval', - to: 'intervalSeconds', - path: `${path}.triggers[${i}].intervalSeconds`, - }); - triggersChanged = true; - return renamed; - }); - if (triggersChanged) next = { ...next, triggers: nextTriggers }; - } - - return next; - }); - }, - fixture: { - before: { - connectors: [ - { - name: 'billing_api', - label: 'Billing API', - type: 'rest', - // No `health` block: `connector-resilience-keys-removed` strips it - // whole, so it may not appear in this fixture (disjointness, §3). - triggers: [ - { key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }, - // A webhook trigger authors no interval and keeps its identity. - { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, - ], - }, - // A connector that authored no trigger keeps its identity (copy-on-write). - { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, - ], - }, - after: { - connectors: [ - { - name: 'billing_api', - label: 'Billing API', - type: 'rest', - triggers: [ - { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, - { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, - ], - }, - { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, - ], - }, - expectedNotices: 1, - }, -}; +/* + * ABSORBED — `connector-health-and-trigger-durations-unit-in-key` (protocol 18, + * #15680 for #14478). It renamed the two connector durations whose names carried + * no unit: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and + * `triggers[].interval` → `intervalSeconds` (the bare token `interval` means + * MILLISECONDS elsewhere in this spec, while a trigger interval meant SECONDS). + * Both halves were then removed with the block each key lived in, inside the + * same unreleased protocol step: the breaker half by + * `connector-resilience-keys-removed` (the whole `health` block), and the trigger + * half by `connector-triggers-removed` below (the whole `triggers` array). + * Composed, a rename followed by a strip of its container is unobservable — any + * pre-18 `health` or `triggers` ends deleted regardless of its inner spelling — + * and the table's disjoint-fixture contract cannot hold a fixture whose + * container another entry deletes (`spec-property-retirement` §0, the + * `agent-knowledge-topics-to-sources` precedent above). So the entry left the + * table. Each removal's prescription names the pre-rename spelling it absorbed; + * the retired-key rows `integration/CircuitBreakerConfig:monitoringWindow` and + * `integration/ConnectorTrigger:interval` stay as the record that the bare + * spellings were retired. + * + * ⚠️ The id was PUBLISHED: the 17.4.0 and 17.5.0 tarballs carry it retired + * (`retired-after.census.json`), and the 17.4.0 changelog names it. Measured + * before it left: no code outside this package named the id, and the chain + * replays only the ids a step lists — so an upgrading reader who greps it finds + * this note and the two removals it points at, and a stored row or artifact + * holding either old spelling meets the removal that deletes its container. + */ /** * `connector.health`, `connector.status` and `connector.webhooks` removed @@ -9756,7 +9689,9 @@ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { * carries that judgement). The whole `health` block goes as one key, so this * entry also serves an author still holding the pre-rename * `circuitBreaker.monitoringWindow` spelling — the rename's breaker half was - * absorbed here (see `connector-health-and-trigger-durations-unit-in-key`). + * absorbed here (see the ABSORBED note for + * `connector-health-and-trigger-durations-unit-in-key` above, whose trigger half + * `connector-triggers-removed` absorbed in turn). * * `retiredFromLoadPath`: `ConnectorSchema` tombstones all three keys * (`retiredKey`, tsc `never` + the parse-time prescription), so a live parse @@ -9827,6 +9762,97 @@ const connectorResilienceKeysRemoved: MetadataConversion = { }, }; +/** + * `connector.triggers` removed (protocol 18 — ADR-0049 enforce-or-remove, by + * ruling on the maintainer's criterion for a declared-but-unenforced family; + * ADR-0041 keeps connector-event triggers in its third tier, as their own + * trigger package). + * + * The whole `ConnectorTrigger` array — `key`, `label`, `description`, + * `type: 'polling' | 'webhook'`, `intervalSeconds` — measured with no reader: + * `AutomationEngine.registerConnector` walks a connector's `actions` only, the + * engine's trigger registry holds FLOW trigger kinds that no connector trigger + * ever entered, no polling loop read an interval and no receiver was driven by + * a `webhook` trigger. No connector package, provider or example declared one. + * + * A pure lossless delete, one notice per connector carrying the key: a trigger + * never started anything, so there is no behaviour to preserve. The array is + * STRIPPED, never turned into flows — what replaces a trigger is an `api` flow + * (an external event) or a `schedule` flow (a scheduled pull) calling the + * connector's action, and which of those should exist, with what cadence and + * which action, is the author's call (the family's D3 entry, + * `connector-triggers-retired`, carries it). The whole array goes as one key, so + * this entry also serves an author still holding the pre-rename + * `triggers[].interval` spelling: that rename was absorbed here (see the + * ABSORBED note for `connector-health-and-trigger-durations-unit-in-key` above). + * + * `retiredFromLoadPath`: `ConnectorSchema` tombstones the key (`retiredKey`, + * tsc `never` + the parse-time prescription), so a live parse refuses loudly. + * This entry exists because a stored connector row CAN carry it — the + * `PUT /meta/connector/:name` door persisted what it parsed, and a descriptor's + * `triggers` parsed clean there — and the rehydration seam + * `applyConversionsToStoredItem('connector', row)` is live for this type; so + * 17.x rows replay clean, and `os migrate meta --from 17` lists the mechanical + * edits for author sources. + */ +const connectorTriggersRemoved: MetadataConversion = { + id: 'connector-triggers-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.5.0', + surface: 'connector.triggers', + summary: + "connector key 'triggers' removed (ADR-0049 — a connector trigger never started anything: " + + 'the automation engine registered a connector\'s actions only, no polling loop read an ' + + 'interval and no receiver was driven by a webhook trigger. The ConnectorTrigger shape went ' + + 'with it, including the `interval` spelling renamed to `intervalSeconds` earlier in this ' + + 'step. Start the work from a flow that calls the connector\'s action instead: an `api` flow ' + + 'for an external event, a `schedule` flow for a scheduled pull)', + apply(stack, emit) { + return mapCollection(stack, 'connectors', (c, path) => + stripKeys(c, ['triggers'], emit, path)); + }, + fixture: { + before: { + connectors: [ + // Minimal by the §3 disjointness contract: the retired key and nothing + // else this major's other `connectors[]` entries walk. The measured + // shape — a polling and a webhook trigger. + { + name: 'billing_api', + label: 'Billing API', + type: 'api', + triggers: [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, + ], + }, + // A stored row written before the unit rename: the absorbed + // `interval` spelling ends with the whole array gone, in one notice. + { + name: 'crm_feed', + label: 'CRM Feed', + type: 'saas', + triggers: [{ key: 'new_lead', label: 'New lead', type: 'polling', interval: 300 }], + }, + // A connector that never authored the key keeps its identity — the + // copy-on-write contract `stripKeys` / `mapCollection` are built on. + { name: 'crm_catalog', label: 'CRM Catalog', type: 'saas' }, + ], + }, + after: { + connectors: [ + { name: 'billing_api', label: 'Billing API', type: 'api' }, + { name: 'crm_feed', label: 'CRM Feed', type: 'saas' }, + { name: 'crm_catalog', label: 'CRM Catalog', type: 'saas' }, + ], + }, + // One per connector carrying the key; the triggers inside the array leave + // with it and are not counted. + expectedNotices: 2, + }, +}; + /** * `datasources[].config.persistence.autoSaveInterval` → `autoSaveIntervalMs` * for the memory driver (protocol 18, #15680 for #14478). @@ -12600,10 +12626,12 @@ export const CONVERSIONS_BY_MAJOR: Readonly { ).toThrow(/must not author `actions`/); }); - it('rejects authored `triggers` on a provider-bound instance (§5)', () => { - expect(() => - DeclarativeConnectorEntrySchema.parse({ - ...validInstance, - triggers: [{ key: 't', label: 'T', type: 'polling' }], - }), - ).toThrow(/must not author `triggers`/); + // This used to pin a provider-bound refusal of `triggers` reasoned "the + // provider derives them from the upstream at boot" — untrue: no provider + // ever derived a trigger. `triggers` is now a retiredKey() tombstone on + // every carrier (ADR-0049), so a provider-bound instance meets the same + // retirement prescription a descriptor does, and never the old reason. + // The full pin set lives in `connector-triggers-retirement.test.ts`. + it('refuses authored `triggers` on a provider-bound instance with the retirement prescription, not a derivation claim', () => { + const result = DeclarativeConnectorEntrySchema.safeParse({ + ...validInstance, + triggers: [{ key: 't', label: 'T', type: 'webhook' }], + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'triggers'); + expect(issue, 'the refusal must name `triggers`').toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch(/^`connector\.triggers` was removed in @objectstack\/spec 17 \(ADR-0049/); + for (const i of result.error!.issues) expect(i.message).not.toContain('derives them from the upstream'); }); }); }); diff --git a/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts index 3716ed91e30..f1f01fee1fd 100644 --- a/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts +++ b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts @@ -282,27 +282,24 @@ describe('connector resilience family retirement — the D2 conversion', () => { expect(stack).toEqual({ connectors: [{ ...WELL_FORMED }] }); }); - it('the absorbed chain: a pre-rename breaker key ends with the whole block gone, and the rename no longer fires on it', () => { + it('the absorbed chain: a pre-rename breaker key ends with the whole block gone, and no rename fires on it', () => { + // The rename's other half (`triggers[].interval`) used to ride in this same + // connector as the control that the rename still fired. It was absorbed in + // turn by the triggers retirement, so the rename left the table; that + // chain is pinned in `connector-triggers-retirement.test.ts`. The control + // here is now a live sibling key the removal must leave alone. const { stack, notices } = collectConversionNotices( { connectors: [{ ...WELL_FORMED, health: { circuitBreaker: { enabled: true, monitoringWindow: 120000 } }, - // The rename's surviving half, in the same connector, as the control: - // it must still fire — this retirement touched only the breaker half. - triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }], + requestTimeoutMs: 12000, }], }, { includeRetired: true }, ); - expect(stack).toEqual({ - connectors: [{ - ...WELL_FORMED, - triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }], - }], - }); + expect(stack).toEqual({ connectors: [{ ...WELL_FORMED, requestTimeoutMs: 12000 }] }); expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ - ['connector-health-and-trigger-durations-unit-in-key', 'interval', 'intervalSeconds'], ['connector-resilience-keys-removed', 'health', '(removed)'], ]); }); @@ -332,11 +329,14 @@ describe('connector resilience family retirement — ADR-0087 registration', () expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('integration/CircuitBreakerConfig:monitoringWindow'); }); - it('wires the D2 conversion into the step-18 chain, AFTER the duration rename it absorbs', () => { + it('wires the D2 conversion into the step-18 chain; the duration rename it absorbed is gone from it', () => { const ids = MIGRATIONS_BY_MAJOR[18]!.conversionIds; expect(ids).toContain('connector-resilience-keys-removed'); - expect(ids.indexOf('connector-resilience-keys-removed')) - .toBeGreaterThan(ids.indexOf('connector-health-and-trigger-durations-unit-in-key')); + // This used to pin the removal AFTER the rename in the chain. The rename + // lost its trigger half to the triggers retirement as well, so with neither + // half left it is no longer in the step at all — an ordering assertion + // against an absent id would pass vacuously. + expect(ids).not.toContain('connector-health-and-trigger-durations-unit-in-key'); }); it('carries ONE D3 entry for the family, naming its D2 conversion and the chain', () => { @@ -370,7 +370,9 @@ describe('connector resilience family retirement — the seven defs leave every expect(holdersOf(name), `${name} must have zero holders`).toEqual([]); } const integrationNames = exportNamesOf('./integration'); - for (const name of ['ConnectorSchema', 'DeclarativeConnectorEntrySchema', 'RetryConfigSchema', 'ConnectorTriggerSchema']) { + // `ConnectorTriggerSchema` was the fourth survivor here until the triggers + // retirement took it whole; `connector-triggers-retirement.test.ts` pins that. + for (const name of ['ConnectorSchema', 'DeclarativeConnectorEntrySchema', 'RetryConfigSchema']) { expect(integrationNames, `${name} must SURVIVE this retirement`).toContain(name); } }); diff --git a/packages/spec/src/integration/connector-triggers-retirement.test.ts b/packages/spec/src/integration/connector-triggers-retirement.test.ts new file mode 100644 index 00000000000..092c75278a5 --- /dev/null +++ b/packages/spec/src/integration/connector-triggers-retirement.test.ts @@ -0,0 +1,484 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connector `triggers` array RETIRED — the whole `ConnectorTrigger` shape + * (`key`, `label`, `description`, `type: 'polling' | 'webhook'`, + * `intervalSeconds`), ADR-0049 enforce-or-remove, one batch. + * + * Ruled RETIRE on the maintainer's criterion for a declared-but-unenforced + * family, with ADR-0041 left as it is: connector-event triggers stay in its + * third tier, as their own trigger package, promoted only when real projects + * ask — and then in the mainstream shape (subscribe / unsubscribe, signature + * verification, a dedupe cursor), which these five keys could not carry. + * Measured before the removal: `registerConnector` walks `parsed.actions` only, + * the engine's trigger registry holds FLOW trigger kinds no connector trigger + * ever entered, no polling loop read an interval, no receiver was driven by a + * `webhook` trigger, and nothing outside `packages/spec` declared one. + * + * Bookkeeping shapes, pinned below: + * 1. One `retiredKey()` tombstone on the non-strict `ConnectorBaseSchema` (a + * bare deletion would be a SILENT STRIP, ADR-0104), carried by both + * published carriers — `ConnectorSchema` and + * `DeclarativeConnectorEntrySchema` — so the refusal reaches + * `registerConnector`, `stack.connectors[]` and the `/meta/connector` + * door. Two `RETIRED_KEYS_BY_MAJOR[18]` rows (one key × two defs). + * 2. The provider-bound refusal of `triggers` (reasoned "the provider derives + * them" — untrue) is GONE: the tombstone refuses every value on every + * carrier, so a provider-bound instance meets the retirement prescription + * and never the old reason. + * 3. `integration/ConnectorTrigger` leaves whole (`RETIRED_DEFS_BY_MAJOR[18]`). + * 4. The D2 conversion `connector-triggers-removed` strips the array from + * `connectors[]` and stored rows, and ABSORBS the trigger half of the same + * step's duration rename: a source still holding `triggers[].interval` + * ends with no `triggers` at all, and the rename left the table. + * 5. The family's D3 entry `connector-triggers-retired`, which names that + * chain. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface really has: refusal, the issue `code`, the `path` + * naming WHICH key refused, and the prescription text (where the wording is + * the contract, pin the wording). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ObjectStackSchema } from '../stack.zod'; +import { EXPORT_ENTRY_POINTS, exportNamesOf, holdersOf } from '../../scripts/lib/export-origins-testkit'; +import { ConnectorSchema, DeclarativeConnectorEntrySchema, type Connector } from './connector.zod'; + +/** A well-formed catalog descriptor — every required key, not the retired one. */ +const WELL_FORMED = { + name: 'billing_api', + label: 'Billing API', + type: 'api', +} as const; + +/** What an author could write under `triggers` before the removal — both types. */ +const AUTHORED_TRIGGERS = [ + { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, + { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, +] as const; + +/** The prescription: named, dated to the npm major, and closed with the house sentence. */ +const PRESCRIPTION = /^`connector\.triggers` was removed in @objectstack\/spec 17 \(ADR-0049/; + +/** + * What the prescription must send the author to — the two shapes that work + * today, as ruled: an external event starts an `api` flow, and a scheduled pull + * is a `schedule` flow, each calling the connector's action. + */ +const POINTS_AT = ['`connector_action` node', 'an `api` flow', '`schedule` flow', 'Delete the key'] as const; + +const MIGRATE_SENTENCE = + /Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\.$/; + +/** The provider-bound refusal's reason, which was untrue and must not survive anywhere. */ +const OLD_REASON = 'derives them from the upstream'; + +function issueAt(result: { success: boolean; error?: { issues: readonly { path: PropertyKey[]; code: string; message: string }[] } }, at: string) { + expect(result.success, `the parse must refuse \`${at}\``).toBe(false); + return result.error!.issues.find((i) => i.path.join('.') === at); +} + +describe('connector triggers retirement — the tombstone', () => { + it('REJECTS an authored `triggers` at path `triggers`, carrying the prescription', () => { + const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, triggers: AUTHORED_TRIGGERS }), 'triggers'); + expect(issue, 'the refusal must name `triggers`').toBeDefined(); + // The machine-readable half this surface has: a `retiredKey()` tombstone + // raises `invalid_type` from its `z.never()` — not `unrecognized_keys`, + // which would mean the key had simply vanished from a strict shape. + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['triggers']); + expect(issue!.message).toMatch(PRESCRIPTION); + for (const target of POINTS_AT) expect(issue!.message).toContain(target); + expect(issue!.message).toMatch(MIGRATE_SENTENCE); + // Customer-facing text carries the ADR, never a tracker number. + expect(issue!.message).not.toMatch(/#\d{3,}/); + }); + + it('refuses EVERY value, both interval spellings and an empty array included', () => { + // The pre-rename `interval` spelling was a tombstone of its own; an author + // who still holds it must meet THIS prescription, which names it, and + // never a rename prescription pointing at a key the parse refuses next. + for (const value of [ + [{ key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }], + [{ key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }], + [], + null, + 'polling', + ]) { + const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, triggers: value }), 'triggers'); + expect(issue, `${JSON.stringify(value)} must be refused AT the key`).toBeDefined(); + expect(issue!.message, `${JSON.stringify(value)} must carry the prescription`).toMatch(PRESCRIPTION); + } + const message = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, triggers: [] }), 'triggers')!.message; + expect(message).toContain('`intervalSeconds` (or the `interval` spelling it was renamed from)'); + }); + + it('the second carrier, a provider-bound instance, the /meta door and `stack.connectors[]` all refuse it — with controls', () => { + const door = getMetadataTypeSchema('connector'); + expect(door, 'no schema bound for `connector`').toBeDefined(); + const instance = { ...WELL_FORMED, provider: 'openapi', providerConfig: { spec: './billing.json' } }; + for (const [label, base] of [['descriptor', WELL_FORMED], ['provider-bound instance', instance]] as const) { + const withKey = { ...base, triggers: AUTHORED_TRIGGERS }; + const entry = issueAt(DeclarativeConnectorEntrySchema.safeParse(withKey), 'triggers'); + expect(entry, `entry refuses triggers on a ${label}`).toBeDefined(); + expect(entry!.message).toMatch(PRESCRIPTION); + // The registry lookup is the real `PUT /meta/connector/:name` entry point: + // a rebinding that pointed `connector` at some third shape would pass the + // pin above and still accept the key in production. + expect(door!.safeParse(withKey).success, `the /meta door refuses triggers on a ${label}`).toBe(false); + const stack = ObjectStackSchema.safeParse({ connectors: [withKey] }); + const stackIssue = issueAt(stack, 'connectors.0.triggers'); + expect(stackIssue, `the stack refusal must locate triggers on a ${label}`).toBeDefined(); + expect(stackIssue!.message).toMatch(PRESCRIPTION); + // ⛔ The provider-bound refusal's untrue reason is gone from every door. + for (const r of [DeclarativeConnectorEntrySchema.safeParse(withKey), stack]) { + for (const i of r.error?.issues ?? []) expect(i.message, label).not.toContain(OLD_REASON); + } + // CONTROL: the same three doors accept the same connector WITHOUT the key, + // so every refusal above is attributable to `triggers` alone. + expect(DeclarativeConnectorEntrySchema.safeParse(base).success, `${label} control`).toBe(true); + expect(door!.safeParse(base).success, `${label} /meta control`).toBe(true); + expect(ObjectStackSchema.safeParse({ connectors: [base] }).success, `${label} stack control`).toBe(true); + } + }); + + it('parses a well-formed connector and grows no `triggers` property', () => { + const parsed = ConnectorSchema.parse({ ...WELL_FORMED }); + expect(parsed.name).toBe('billing_api'); + // CONTROL: the live defaults still apply, so an empty reading below is the + // retirement and not a schema that stopped emitting. + expect(parsed.enabled).toBe(true); + expect(parsed).not.toHaveProperty('triggers'); + }); + + it('the walked shape keeps `triggers` as a key — the ledger row and the authorable-surface row stay reachable', () => { + for (const [label, schema] of [ + ['base', ConnectorSchema], + ['entry', DeclarativeConnectorEntrySchema], + ] as const) { + const shape = (schema as unknown as { shape?: Record }).shape; + expect(shape, `${label} must expose a read-through shape`).toBeDefined(); + expect(Object.keys(shape!), `${label} keeps triggers walkable`).toContain('triggers'); + expect(Object.keys(shape!), `${label} keeps its live neighbour`).toContain('actions'); + } + }); + + it('fails tsc at the authoring site: the input type of the key is `never`', () => { + const connector: Connector = { + ...WELL_FORMED, + // @ts-expect-error — `triggers` is a retiredKey() tombstone: its input type is `never`. + triggers: AUTHORED_TRIGGERS, + }; + // The parse channel agrees with the type channel on the same literal. + expect(ConnectorSchema.safeParse(connector).success).toBe(false); + }); +}); + +describe('connector triggers retirement — the D2 conversion', () => { + it('a STORED connector row carrying `triggers` is converted losslessly through the rehydration seam', () => { + // The `PUT /meta/connector/:name` door persisted what it parsed, and a + // descriptor's `triggers` parsed clean there; `applyConversionsToStoredItem` + // is live for the `connector` type. Measured here rather than assumed. + const stored: Record = { + ...WELL_FORMED, + description: 'Invoices from the billing system', + actions: [{ key: 'get_invoice', label: 'Get invoice', effect: 'read' }], + requestTimeoutMs: 12000, + triggers: AUTHORED_TRIGGERS, + }; + const notices: { conversionId?: string; path?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('connector', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toEqual(['connector-triggers-removed']); + // LOSSLESS: the row that comes back is the stored row minus `triggers`, key + // for key — the array had no effect to preserve, and nothing else moved. + const { triggers: _dropped, ...rest } = stored; + expect(rehydrated).toEqual(rest); + // …and the result is exactly what the tombstoned door accepts. + expect(DeclarativeConnectorEntrySchema.safeParse(rehydrated).success).toBe(true); + // The input is never mutated (copy-on-write). + expect(stored.triggers).toBe(AUTHORED_TRIGGERS); + }); + + it('strips the key from `connectors[]` — one attributed notice per connector — and is idempotent', () => { + const { stack, notices } = collectConversionNotices( + { + connectors: [ + { ...WELL_FORMED, triggers: AUTHORED_TRIGGERS }, + // Never authored the key: rides through untouched. + { name: 'crm_catalog', label: 'CRM Catalog', type: 'saas' }, + ], + }, + { includeRetired: true }, + ); + expect(stack).toEqual({ + connectors: [ + { ...WELL_FORMED }, + { name: 'crm_catalog', label: 'CRM Catalog', type: 'saas' }, + ], + }); + expect(notices.map((n) => [n.conversionId, n.path, n.to])).toEqual([ + ['connector-triggers-removed', 'connectors[0].triggers', '(removed)'], + ]); + // Never turned into flows: writing a flow would START work that never ran. + expect(stack).not.toHaveProperty('flows'); + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference (the copy-on-write contract). + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('the absorbed chain: a pre-rename `interval` trigger ends with the whole array gone, in one notice and no rename', () => { + const { stack, notices } = collectConversionNotices( + { + connectors: [{ + ...WELL_FORMED, + triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }], + // CONTROL: a live sibling key the removal must leave alone. + requestTimeoutMs: 12000, + }], + }, + { includeRetired: true }, + ); + expect(stack).toEqual({ connectors: [{ ...WELL_FORMED, requestTimeoutMs: 12000 }] }); + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ + ['connector-triggers-removed', 'triggers', '(removed)'], + ]); + // The rename that used to fire first is gone from the table, with both of + // its halves absorbed (the breaker half by the `health` removal). + expect(ALL_CONVERSIONS.map((c) => c.id)).not.toContain('connector-health-and-trigger-durations-unit-in-key'); + }); +}); + +describe('connector triggers retirement — ADR-0087 registration', () => { + it('declares both carrier keys and the removed def under major 18, and keeps the `interval` record', () => { + for (const def of ['integration/Connector', 'integration/DeclarativeConnectorEntry']) { + expect(RETIRED_KEYS_BY_MAJOR[18], `${def}:triggers must be declared`).toContain(`${def}:triggers`); + } + expect(RETIRED_DEFS_BY_MAJOR[18]).toContain('integration/ConnectorTrigger'); + // The absorbed rename's tombstone row stays: its def left whole, which is + // the steady state gate (b3) exempts — deleting the row would erase the + // record that the bare `interval` spelling was ever retired. + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('integration/ConnectorTrigger:interval'); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.conversionIds; + expect(ids).toContain('connector-triggers-removed'); + expect(ids).not.toContain('connector-health-and-trigger-durations-unit-in-key'); + const conversion = ALL_CONVERSIONS.find((c) => c.id === 'connector-triggers-removed'); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('connector.triggers'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion, the chain and the two working shapes', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === 'connector-triggers-retired'); + expect(entries, 'the family needs its own D3 entry (ruling B)').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain('`connector-triggers-removed`'); + expect(entry!.reason).toContain('`connector-health-and-trigger-durations-unit-in-key`'); + expect(entry!.replacement).toContain('`api` flow'); + expect(entry!.replacement).toContain('`schedule` flow'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + // The absorbed rename's own D3 entry left with its conversion. + expect(MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id)).not.toContain('connector-resilience-durations-unit-in-key'); + }); +}); + +describe('connector triggers retirement — the def leaves every public entry', () => { + // The two names the retired def exported (it declared no `…Parsed` alias — + // its input and output types coincided). + const RETIRED_NAMES = ['ConnectorTriggerSchema', 'ConnectorTrigger'] as const; + + it('every retired name has ZERO holders on any public entry; the carriers survive', () => { + // Anti-vacuity: the baseline must cover the real surface. + expect(EXPORT_ENTRY_POINTS).toContain('./integration'); + expect(exportNamesOf('./integration').length).toBeGreaterThan(20); + for (const name of RETIRED_NAMES) { + expect(holdersOf(name), `${name} must have zero holders`).toEqual([]); + } + const integrationNames = exportNamesOf('./integration'); + for (const name of ['ConnectorSchema', 'DeclarativeConnectorEntrySchema', 'ConnectorActionSchema']) { + expect(integrationNames, `${name} must SURVIVE this retirement`).toContain(name); + } + }); + + it('the integration barrel resolves without the retired schema', async () => { + const integration = await import('./index'); + expect(integration).not.toHaveProperty('ConnectorTriggerSchema'); + expect(integration).toHaveProperty('ConnectorSchema'); + }); +}); + +// ─── Tree-scoped absence, inside the radius already declared for this package ─ +// +// What this leg guarantees. `tsc` is the primary sweeper — the retired key is +// typed `never` and the retired export is gone, so a TypeScript authoring site +// fails to compile. The residue is everything `tsc` never compiles: JSON, YAML, +// MD, MDX and untyped `.js` / `.mjs` / `.cjs`. This walk covers that residue +// across the five repo roots `scripts/cross-package-test-inputs.mjs` already +// declares for `@objectstack/spec#test` (the #15513 radius, mirrored in +// `turbo.json`), so a resurrection inside it puts this suite into +// `turbo ls --affected`. The bound, stated: `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside the walk. +// +// The matcher judges AUTHORING SHAPES, never a mention. The carrier key +// (`triggers`) and four of its five leaves (`key`, `label`, `description`, +// `intervalSeconds`) are too common a spelling to judge by text — `triggers:` +// is authored on every webhook — so they are held by `tsc` and the parse refusal +// above. What IS distinctive is held here: a `type: 'polling'` pair (the only +// schema that ever declared that value under a `type` key; the realtime +// `polling` transport lives under `transport`), and the retired def's exported +// names, imported from the spec package or used as a schema value. +describe('tree-scoped absence: nothing inside the declared radius still authors the family', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared (the #15513 bound). */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const NAMES = 'ConnectorTrigger'; + const AUTHORING = [ + // A polling trigger's `type` in key position (TS / JSON / YAML). + /(^|[^\w.])["']?type["']?\s*:\s*["']polling["']/m, + // The retired name imported from the spec package. + new RegExp(`import\\s+(type\\s+)?\\{[^}]*\\b(${NAMES})(Schema|Parsed)?\\b[^}]*\\}\\s*from\\s*['"]@objectstack/spec`, 'm'), + // The retired schema used as a value. + new RegExp(`\\b(${NAMES})Schema\\s*\\.\\s*(parse|safeParse|parseAsync|safeParseAsync|shape|extend|options)\\b|typeof\\s+(${NAMES})Schema\\b`, 'm'), + ]; + + /** + * Prose mentions are spelled in INLINE CODE throughout this repo — the house + * style `check:doc-authoring` enforces — so stripping single-backtick spans + * separates "the retirement kit describing what it removed" from "a source + * still writing it". Newline-bounded: a fenced block's content is NOT + * stripped, so an authoring inside a fenced example is still caught. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit and its projections, each with + * its reason. ⛔ NOT an allowlist file (`spec-property-retirement` §4). + */ + const EXCLUDED = new Set([ + // The declaring file: the tombstone and the removal record. + 'packages/spec/src/integration/connector.zod.ts', + // The ledger row `retiredKey()` keeps in the walked shape. + 'packages/spec/liveness/connector.json', + // The pre-release authorable baseline: written ONLY by `gen:authorable-surface-base`, + // never hand-edited or reverted — it is the record the removal is judged against. + 'packages/spec/authorable-surface.base.json', + // This pin names the family to assert its absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion, its fixture and the strip target. + 'packages/spec/src/conversions/', + // Registers the retirement by key and def (entries + the generated registry). + 'packages/spec/src/migrations/', + // Generated projections of the registry. + 'packages/spec/spec-changes.json', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + '.changeset/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `connector.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build (#15513's measured ENOENT). */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention (anti-vacuity)', () => { + expect(judge(" triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }],")).not.toBeNull(); + expect(judge(' { "key": "new_lead", "type": "polling" }')).not.toBeNull(); + expect(judge(' type: polling')).toBeNull(); // bare YAML scalar: not this spelling, stated + expect(judge(" - type: 'polling' # yaml")).not.toBeNull(); + expect(judge("import { ConnectorTriggerSchema } from '@objectstack/spec/integration';")).not.toBeNull(); + expect(judge("import type {\n Connector,\n ConnectorTrigger,\n} from '@objectstack/spec';")).not.toBeNull(); + expect(judge('ConnectorTriggerSchema.parse({ key: "k" })')).not.toBeNull(); + expect(judge('type T = z.infer;')).not.toBeNull(); + // ⛔ NARROWNESS of the strip: a real authoring sharing a line with inline code still counts. + expect(judge("// see `actions` — type: 'polling',")).not.toBeNull(); + // Prose: the retirement kit must be able to describe what it removed. + expect(judge("the `ConnectorTrigger` shape (`type: 'polling' | 'webhook'`) leaves with it")).toBeNull(); + expect(judge('"integration/ConnectorTrigger:type",')).toBeNull(); + // Neighbours that merely share a word: the realtime transport, a webhook + // `triggers` list and a webhook-typed node stay legal. + expect(judge("transport: 'polling',")).toBeNull(); + expect(judge("triggers: ['create', 'update'],")).toBeNull(); + expect(judge(" type: 'webhook',")).toBeNull(); + expect(judge(" subtype: 'polling',")).toBeNull(); + }); + + it('no authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim()}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree. + expect(visited).toBeGreaterThan(1000); + expect(offenders, 'an authoring of the retired family means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index ff2d4d60b0e..ad4fc083f87 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -30,10 +30,10 @@ import { // `connector.status`, ADR-0049; pinned in // `connector-resilience-keys-retirement.test.ts`.) - // Trigger (declared-but-unread, #3197 — the pin block at the bottom judges - // its unit-carrying key name, not a runtime it does not have) - ConnectorTriggerSchema, - + // (The trigger shape — `ConnectorTriggerSchema` — was retired with + // `connector.triggers`, ADR-0049; `connector-triggers-retirement.test.ts` + // pins its absence and the refusal of both of its interval spellings.) + // Types type Connector, type ConnectorFieldMapping, @@ -1323,32 +1323,11 @@ describe('[#14676] ADR-0087 registration', () => { }); }); -// #15680 (stack card 5/6 of #14478) — ruling B. The old trigger spelling is a -// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, -// never on a bare `toThrow()`. The shape is not strict, so without the -// tombstone the old key would be STRIPPED in silence and a polling trigger -// would lose its cadence entirely. -// -// This block also pinned the breaker half of the same card — -// `CircuitBreakerConfig.monitoringWindow` → `monitoringWindowMs` and the -// `resetTimeoutMs` neighbour. That half left with the whole `health` block -// (ADR-0049, `connector-resilience-keys-retirement.test.ts`), which pins that -// both breaker spellings now meet the `health` prescription. -describe('connector durations carry their unit (#15680)', () => { - it('REFUSES the retired trigger `interval` with the rename in the message', () => { - const result = ConnectorTriggerSchema.safeParse({ - key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60, - }); - expect(result.success).toBe(false); - const issue = result.error!.issues.find((i) => i.path.join('.') === 'interval'); - expect(issue).toBeDefined(); - expect(issue!.code).not.toBe('unrecognized_keys'); - expect(issue!.message).toContain('`ConnectorTrigger.interval` was renamed to `intervalSeconds`'); - }); - - it('accepts the new trigger spelling', () => { - expect(ConnectorTriggerSchema.parse({ - key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60, - }).intervalSeconds).toBe(60); - }); -}); +// #15680 (stack card 5/6 of #14478) — ruling B pinned the connector durations +// that carried no unit here: `CircuitBreakerConfig.monitoringWindow` → +// `monitoringWindowMs` and `ConnectorTrigger.interval` → `intervalSeconds`. Both +// halves left with the container each key lived in (ADR-0049, the same +// unreleased protocol step): the breaker half with the whole `health` block +// (`connector-resilience-keys-retirement.test.ts`), and the trigger half with the +// whole `triggers` array (`connector-triggers-retirement.test.ts`), each of which +// pins that both spellings of its key now meet the removal's prescription. diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 704684dc6f0..bf5aacf1692 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -27,8 +27,8 @@ import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; * * This protocol supports multiple authentication strategies, bidirectional sync, * field mapping, and an executed retry policy. It declares no health probe, no - * circuit breaker, no authored status and no webhooks of its own — see "What - * this layer does NOT provide" below. + * circuit breaker, no authored status, no webhooks and no triggers of its own — + * see "What this layer does NOT provide" below. * * ## What this layer does NOT provide * @@ -72,6 +72,14 @@ import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; * "REMOVED: `health`, `status` and the nested `webhooks`" section below records * the measurement. * + * **There are no connector triggers.** The `triggers` array (`polling` / + * `webhook`) was removed in `@objectstack/spec` 17 (ADR-0049 enforce-or-remove): + * nothing ever registered, polled or received one, so a declared trigger never + * started a flow. Work starts from a FLOW that calls the connector's action in a + * `connector_action` node — an `api` flow for an external event, a `schedule` + * flow for a scheduled pull. The "REMOVED: `triggers`" section below records the + * measurement. + * * `connectionTimeoutMs` used to be the second exception and is now **removed** * (ADR-0049, the narrower second decision that surface was owed): it was * carried to a provider factory but never applied as a deadline anywhere, and @@ -688,8 +696,10 @@ const CONNECTOR_RETIRED_KEY_RESIDUE = { // in this same unreleased protocol step; the rename's breaker half is ABSORBED // by this removal (`spec-property-retirement` §0): a renamed key that is then // stripped with its whole block is unobservable, and the conversion table's -// disjoint-fixture contract cannot hold both. `triggers[].interval` → -// `intervalSeconds` is a different family and is untouched. +// disjoint-fixture contract cannot hold both. The same rename's other half, +// `triggers[].interval` → `intervalSeconds`, was a different family and was +// left standing here; it was absorbed in turn when the whole `triggers` array +// was retired — see "REMOVED: `triggers`" below. /** * The prescription an author meets when they write `health` — in `tsc` (the @@ -744,6 +754,73 @@ const WEBHOOKS_RETIRED = + '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; apply them by hand.'; +// ============================================================================ +// REMOVED: `triggers` (ADR-0049) +// ============================================================================ +// +// `connector.triggers` — the `ConnectorTrigger` shape (`key`, `label`, +// `description`, `type: 'polling' | 'webhook'`, `intervalSeconds`) — declared a +// connector-owned way to start an automation, and NOTHING ever read it. Its own +// docblock said so ("NOT YET ENFORCED — declared but never read by the +// runtime"). Measured on `origin/main` before the removal: +// `AutomationEngine.registerConnector` walks `parsed.actions` only and stores the +// rest of the def unread; the engine's trigger registry holds FLOW trigger kinds +// (`record_change`, `time_relative`, `schedule`, `api` — a closed set) and no +// connector trigger ever entered it; no polling loop read `intervalSeconds`; no +// receiver was driven by a `webhook` trigger; and no connector package, provider +// or example declared one. The only runtime touch was a REFUSAL on a +// provider-bound declarative instance, whose reason ("the provider derives them +// from the upstream at boot") was itself untrue — no provider derives a trigger. +// +// Retired rather than built, by ruling on the maintainer's criterion for a +// declared-but-unenforced family: the accepted ADR-0041 places connector-event +// triggers (webhook-subscribe and poll) in its third tier, as their own trigger +// package, promoted only when real projects ask for it. When that happens the +// family returns in the mainstream shape — a subscribe / unsubscribe lifecycle, +// signature verification, a dedupe cursor — which these five keys could not +// carry. What works today, and what the prescription below names, is a FLOW that +// calls the connector's action: an external event starts an `api` flow, and a +// scheduled pull is a `schedule` flow. +// +// `ConnectorSchema` is NOT `.strict()`, so a plain delete would be a silent strip +// (ADR-0104): `triggers` is a `retiredKey()` tombstone below, inherited by +// `DeclarativeConnectorEntrySchema` because both published carriers wrap the same +// private `ConnectorBaseSchema`. That made the provider-bound refusal unreachable +// (every carrier now refuses the key outright, with the prescription), so the +// rule and its untrue reason left with it. `integration/ConnectorTrigger` leaves +// whole (`RETIRED_DEFS_BY_MAJOR[18]`), because an exported value schema with no +// consumer reads as a capability. Registered as `integration/Connector:triggers` +// and `integration/DeclarativeConnectorEntry:triggers` in +// `RETIRED_KEYS_BY_MAJOR[18]`; authored sources and stored rows are rewritten by +// the D2 conversion `connector-triggers-removed`, and the family's judgement +// lives in the D3 entry `connector-triggers-retired`. +// +// `triggers[].interval` → `intervalSeconds` was renamed earlier in this same +// unreleased protocol step; that rename is ABSORBED by this removal +// (`spec-property-retirement` §0), as its breaker half was by the `health` +// removal above: a key renamed and then stripped with its whole array is +// unobservable, and the conversion table's disjoint-fixture contract cannot hold +// both. The `integration/ConnectorTrigger:interval` registration stays — it is +// still the record that the bare `interval` spelling was retired. + +/** + * The prescription an author meets when they write `triggers` on a connector — + * in `tsc` (the key's input type is `never`) and at parse (this string is the + * issue message). It serves the author who still holds the pre-rename + * `interval` spelling too; the closing sentence is the house `os migrate meta` + * form pinned by `shared/retired-key-migrate-sentence.test.ts`. + */ +const TRIGGERS_RETIRED = + '`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; apply them by hand.'; + // ============================================================================ // Base Connector Schema // ============================================================================ @@ -836,35 +913,10 @@ export const ConnectorActionSchema = lazySchema(() => z.object({ })); export type ConnectorAction = z.input; -/** - * Connector Trigger Definition - * - * ⚠️ NOT YET ENFORCED — declared but never read by the runtime (#3197). - * `AutomationEngine.registerConnector` ignores a connector's `triggers`; the - * only runtime touch is the authoring-time reject rule that forbids triggers - * on provider-bound declarative instances (ADR-0097 §5). No polling loop or - * webhook receiver is driven by these definitions. - */ -export const ConnectorTriggerSchema = lazySchema(() => z.object({ - key: z.string().describe('Trigger key'), - label: z.string().describe('Trigger label'), - description: z.string().optional(), - type: z.enum(['polling', 'webhook']).describe('Trigger type'), - // Renamed from `interval` (#15680, ruling B on #14478): the unit lived only in - // the describe prose, and a polling cadence is exactly the number a reader - // guesses at — the same bare `interval` means MILLISECONDS elsewhere in this - // spec, so the identical name carried two units a thousandfold apart. - intervalSeconds: z.number().optional().describe('Polling interval in seconds'), - - /** Tombstone for the rename above (#15680, ruling B on #14478). */ - interval: retiredKey( - '`ConnectorTrigger.interval` was renamed to `intervalSeconds` in @objectstack/spec 17 — ' - + 'the unit of a duration-shaped number lives in the key name, not only in the describe ' - + 'prose. Rename the key to `intervalSeconds`; the value (seconds) is unchanged. ' - + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', - ), -})); -export type ConnectorTrigger = z.input; +// `ConnectorTriggerSchema` / `ConnectorTrigger` (`key`, `label`, `description`, +// `type: 'polling' | 'webhook'`, `intervalSeconds`, and the `interval` tombstone +// of its unit rename) used to be declared here. It left whole with the +// `triggers` key it was the only carrier of — see "REMOVED: `triggers`" above. /** * Base Connector Schema @@ -956,7 +1008,18 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ /** Zapier-style Capabilities */ actions: z.array(ConnectorActionSchema).optional(), - triggers: z.array(ConnectorTriggerSchema).optional().describe('Trigger definitions '), + + /** + * `triggers` — RETIRED (ADR-0049 enforce-or-remove). A connector trigger never + * started anything: `registerConnector` registers actions only, no polling + * loop or receiver was driven by one, and no provider derives one. What starts + * work from an external system is a flow calling the connector's action — an + * `api` flow for an external event, a `schedule` flow for a scheduled pull. + * `ConnectorSchema` is NOT `.strict()`, so a plain delete would be a silent + * strip (ADR-0104); the tombstone makes the removal audible in `tsc` and at + * parse. See "REMOVED: `triggers`" above. + */ + triggers: retiredKey(TRIGGERS_RETIRED), /** * Data synchronization configuration @@ -1182,9 +1245,17 @@ export function defineConnector(config: z.input): Connec * The remaining rules key off `provider` — instance vs. catalog descriptor: * - `providerConfig` / `auth` require a `provider`; on a pure descriptor they * are meaningless materialization inputs, so they are rejected. - * - A provider-bound instance must NOT author `actions` / `triggers` — the - * provider derives them from the upstream (OpenAPI document / MCP `tools/list`); - * authoring both the instance and its actions reintroduces drift (§5 non-goals). + * - A provider-bound instance must NOT author `actions` — the provider derives + * them from the upstream (OpenAPI document / MCP `tools/list`); authoring both + * the instance and its actions reintroduces drift (§5 non-goals). + * + * `triggers` used to be the second key of that last rule, refused with the + * reason that the provider derives triggers too. That reason was untrue — no + * provider ever derived a trigger — and the rule is gone rather than corrected: + * `triggers` is now a `retiredKey()` tombstone on the shared base, so every + * carrier — descriptor and instance alike — refuses any value with the + * retirement prescription, and a provider-bound refusal could only ever repeat + * that verdict with a wrong reason (see "REMOVED: `triggers`" above). */ export const DeclarativeConnectorEntrySchema = lazySchema(() => // [#12840 precedent] The ADR-0097 refusals ride on the BASE, INSIDE the @@ -1235,13 +1306,6 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => message: `Provider-bound connector instance '${entry.name}' must not author \`actions\` — the '${entry.provider}' provider derives them from the upstream at boot (ADR-0097 §5).`, }); } - if (entry.triggers && entry.triggers.length > 0) { - ctx.addIssue({ - code: 'custom', - path: ['triggers'], - message: `Provider-bound connector instance '${entry.name}' must not author \`triggers\` — the '${entry.provider}' provider derives them from the upstream at boot (ADR-0097 §5).`, - }); - } }), CONNECTOR_RETIRED_KEY_RESIDUE), ); diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorTrigger.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorTrigger.ts new file mode 100644 index 00000000000..8dd5f27312f --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorTrigger.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/ConnectorTrigger` (`key`, `label`, `description`, +// `type: 'polling' | 'webhook'`, `intervalSeconds`, and the `interval` tombstone +// of its unit rename) leaves with its only carrier, `ConnectorSchema.triggers`, +// tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a +// connector trigger, so nothing replaces the shape: work starts from a flow that +// calls the connector's action. See +// `retired-keys/18.integration__Connector__triggers.ts` for the retirement +// record. +export const entry = 'integration/ConnectorTrigger'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts index ab2af9c412f..987911818f0 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts @@ -22,5 +22,6 @@ // `monitoringWindow` spelling was retired. The rename's breaker half was absorbed // by `connector-resilience-keys-removed`, which strips the block an author // holding either spelling still carries; the `health` tombstone's prescription -// names both spellings. +// names both spellings. (Its trigger half was absorbed later in the same step by +// `connector-triggers-removed`, so the rename conversion itself left the table.) export const entry = 'integration/CircuitBreakerConfig:monitoringWindow'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts index 724af5404b7..ee67ba9a295 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__ConnectorTrigger__interval.ts @@ -9,7 +9,15 @@ // value is unchanged. Tombstoned with `retiredKey()`; the shape is not // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 // conversion `connector-health-and-trigger-durations-unit-in-key`. -// ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven -// by it). The rename does not change that; it makes the declaration honest -// about its unit for whoever implements the loop. +// +// ⚠️ Superseded in the same unreleased step: the trigger shape was declared but +// never read (no polling loop was driven by it), and the whole `triggers` array +// was then retired under ADR-0049 — `integration/ConnectorTrigger` left whole +// (`RETIRED_DEFS_BY_MAJOR[18]`) and this tombstone left with it. The row STAYS — +// the whole-def removal steady state gate (b3) exempts — because it is still the +// record that the bare `interval` spelling was retired (the +// `integration/CircuitBreakerConfig:monitoringWindow` precedent). The rename +// conversion left the table, both of its halves absorbed; the trigger half by +// `connector-triggers-removed`, which strips the array an author holding either +// spelling still carries, and the `triggers` tombstone's prescription names both. export const entry = 'integration/ConnectorTrigger:interval'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__triggers.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__triggers.ts new file mode 100644 index 00000000000..c8a1f930f5b --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__triggers.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `ConnectorSchema.triggers` — the connector +// triggers family, ruled RETIRE on the maintainer's criterion for a +// declared-but-unenforced family, with ADR-0041 left as it is (connector-event +// triggers stay in its third tier, as their own trigger package, promoted only +// when real projects ask for them). The `ConnectorTrigger` array (`key`, +// `label`, `description`, `type: 'polling' | 'webhook'`, `intervalSeconds`) was +// read by NOTHING. Measured on `origin/main` before the removal: +// `AutomationEngine.registerConnector` walks `parsed.actions` only and stores the +// rest of the def unread; the engine's trigger registry is keyed by FLOW trigger +// kind (`record_change`, `time_relative`, `schedule`, `api` — a closed set) and +// no connector trigger ever entered it; no polling loop read `intervalSeconds`; +// no receiver was driven by a `webhook` trigger; and across every `.ts`, `.tsx` +// and `.json` file outside `packages/spec` (tests excluded) at `288611e3e5`, the +// 15 authorings of a `triggers:` key were all webhook `triggers`, a plugin +// grouping or form-label translations — none a connector trigger — while +// `actions`, the lit control on the same def, is walked by `registerConnector` +// and handler-checked there. +// +// The one runtime touch was a REFUSAL of `triggers` on a provider-bound +// declarative instance, reasoned "the provider derives them from the upstream at +// boot" — untrue, since no provider ever derived a trigger. The tombstone makes +// that rule unreachable (every carrier refuses every value), so the rule left +// with it rather than being re-reasoned. +// +// Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned +// `integration/ConnectorTrigger` leaves via `RETIRED_DEFS_BY_MAJOR[18]`. The key +// carried no default, so no retired-default residue is owed. Sources and stored +// rows are rewritten by the D2 conversion `connector-triggers-removed`, which +// STRIPS the array and never turns a trigger into a flow — that is the author's +// decision, carried by the D3 entry `connector-triggers-retired`. +export const entry = 'integration/Connector:triggers'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__triggers.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__triggers.ts new file mode 100644 index 00000000000..695dd5b4b91 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__triggers.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The same `triggers` tombstone seen through the second carrier — the shape +// `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, where the +// provider-bound refusal the tombstone replaced used to live. One tombstone, two +// registered keys, EXACT per-def membership (gate (b)). See +// `18.integration__Connector__triggers.ts` for the retirement record. +export const entry = 'integration/DeclarativeConnectorEntry:triggers'; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts deleted file mode 100644 index 9f423477a1d..00000000000 --- a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import type { SemanticMigration } from '../../types.js'; - -// #15680 (stack card of #14478, maintainer ruling B: a duration key carries its -// unit in its NAME) — the D3 entry of the -// `connector-health-and-trigger-durations-unit-in-key` family (ruling B on -// #17152: one D3 entry per retirement family, even when D2 is lossless). The -// family was two keys in one authored document and one conversion: -// `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and -// `triggers[].interval` → `intervalSeconds`. -// -// ⚠️ Reconciled with the connector resilience retirement (ADR-0049, the same -// unreleased protocol step): the whole `health` block was then removed, so the -// breaker half of this rename was ABSORBED — the renamed key is itself retired, -// and the conversion now carries only the trigger half. This entry says so, -// rather than prescribing a rename to a key the parse refuses next; the -// removal's own judgement is the D3 entry `connector-resilience-keys-retired`. -// `triggers[].interval` is still unread (the liveness ledger records it dead, -// `liveness/connector.json`): the rename is an honesty fix to the declaration, -// and the entry says so rather than implying a live engine. -export const entry: SemanticMigration = { - id: 'connector-resilience-durations-unit-in-key', - surface: 'connector.triggers[].interval — the connector duration whose name carried no unit ' - + '(and, until the whole `health` block was retired, connector.health.circuitBreaker.monitoringWindow)', - replacement: '`intervalSeconds` (seconds) — rename the key; the value is unchanged. There is no ' - + 'replacement for `monitoringWindow`: its renamed spelling `monitoringWindowMs` was retired with ' - + 'the rest of `connector.health` — delete the block (see `connector-resilience-keys-retired`).', - reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames ' - + '`triggers[].interval` in `connectors[]` and on stored connector rows, keeping the value; the ' - + 'rename is lossless because the key always meant seconds. It used to rename the breaker\'s ' - + '`monitoringWindow` too, but that half was absorbed by `connector-resilience-keys-removed`, ' - + 'which strips the whole `health` block — so an author holding either `monitoringWindow` or ' - + '`monitoringWindowMs` ends with no key at all, and must not re-add `monitoringWindowMs`: the ' - + 'parse refuses the block. Two judgments remain for the trigger. First, the unit was easy to ' - + 'get wrong: the bare token `interval` means MILLISECONDS elsewhere in this same spec while a ' - + 'trigger interval meant SECONDS — so a trigger written `interval: 60000` for one minute asked ' - + 'for once every sixteen hours or so, and the rename keeps 60000. Second, the key drives no ' - + 'engine today: no polling loop reads a trigger interval, so an author who relied on it for ' - + 'behaviour has not been getting it, before or after this rename.', - acceptanceCriteria: 'No connector carries `triggers[].interval`; the parse refuses it with the ' - + 'rename, and every `intervalSeconds` value is the cadence the author intends in seconds — a ' - + 'trigger meant to poll every minute reads `intervalSeconds: 60`. No connector carries ' - + '`health` in any spelling (`monitoringWindow` or `monitoringWindowMs` included). No part of ' - + 'the deployment\'s design depends on a connector polling on that interval or tripping on a ' - + 'breaker window: where it did, the author has moved that need to a mechanism that runs.', -}; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts index 90825991fd4..79c6d810275 100644 --- a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts @@ -11,7 +11,9 @@ import type { SemanticMigration } from '../../types.js'; // what only the author can judge. It also names the CHAIN through the same // protocol step: `connector-health-and-trigger-durations-unit-in-key` used to // rename `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`, and -// that half was absorbed here — the renamed key is itself removed. +// that half was absorbed here — the renamed key is itself removed. (Its trigger +// half was absorbed later by `connector-triggers-removed`, and the conversion +// left the table.) export const entry: SemanticMigration = { id: 'connector-resilience-keys-retired', surface: 'connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — ' @@ -38,8 +40,10 @@ export const entry: SemanticMigration = { + 'counterpart there. The chain: in this same protocol step, ' + '`connector-health-and-trigger-durations-unit-in-key` no longer renames ' + '`health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that ' - + 'key lived in is removed, so an author holding either spelling ends with no key at all; ' - + 'that conversion\'s `triggers[].interval` to `intervalSeconds` rename is unaffected.', + + 'key lived in is removed, so an author holding either spelling ends with no key at all. ' + + 'That conversion\'s other half, `triggers[].interval` to `intervalSeconds`, was absorbed ' + + 'the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), ' + + 'so the rename itself is no longer in the step.', acceptanceCriteria: 'No connector and no stack connector entry carries `health`, `status` or ' + '`webhooks`; the parse refuses each with its prescription (a stored `status: \'inactive\'` ' + 'default is accepted and stripped as inert residue), and no code imports ConnectorHealth, ' diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-triggers-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-triggers-retired.ts new file mode 100644 index 00000000000..725b9594fed --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-triggers-retired.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0049 enforce-or-remove — the D3 entry of the connector triggers family: +// `connector.triggers`, the whole `ConnectorTrigger` array, retired as one +// batch by ruling (ADR-0041 unchanged: connector-event triggers stay in its +// third tier, as their own trigger package). One D3 entry per retirement +// family, even when D2 is lossless (ruling B on #17152): the D2 conversion +// `connector-triggers-removed` repairs the data, and this entry carries what +// only the author can judge. It also names the CHAIN through the same protocol +// step: `connector-health-and-trigger-durations-unit-in-key` used to rename +// `triggers[].interval` to `intervalSeconds`; that half was absorbed here (the +// breaker half already was, by the `health` removal), so the rename left the +// table, and this family's former rename entry left with it. +export const entry: SemanticMigration = { + id: 'connector-triggers-retired', + surface: 'connector.triggers — the ConnectorTrigger array (key / label / description / type / ' + + 'intervalSeconds, and the interval spelling it was renamed from), on a connector and on a ' + + 'stack connectors[] entry', + replacement: '(removed — nothing replaces a connector trigger.) Start the work from a flow that ' + + 'calls the connector\'s action in a `connector_action` node: an external event starts an ' + + '`api` flow that the event\'s sender calls, and a scheduled pull is a `schedule` flow.', + reason: 'The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, ' + + 'stack entry and stored connector row, one notice per connector, and the delete is lossless: ' + + 'the automation engine registered a connector\'s actions only, no polling loop read an ' + + 'interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a ' + + 'declared trigger never started a flow, before or after the upgrade. Three judgements ' + + 'remain. First, any part of the deployment designed around a connector trigger firing has ' + + 'never been running, so the author decides which of those triggers should now exist as ' + + 'flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls ' + + 'the connector\'s read action, and a `webhook` trigger becomes an `api` flow that the ' + + 'external sender calls. The conversion STRIPS the array and never writes a flow, because ' + + 'a flow that runs STARTS work that never happened before — its cadence, its action and ' + + 'what it does with the result are the author\'s. Second, a polling cadence is in SECONDS: ' + + 'the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol ' + + 'step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger ' + + 'written `interval: 60000` for one minute asked for once every sixteen hours or so — carry ' + + 'the intended cadence, not the stored number, into the schedule. Third, turning a ' + + '`webhook` trigger into an `api` flow opens an inbound endpoint that never existed before ' + + '(the trigger declared no receiver and no verification), and the platform refuses an `api` ' + + 'flow with no per-flow secret and verifies a signature on every call — so whether the ' + + 'external sender can sign its calls decides whether that flow can receive them directly. ' + + 'The chain: ' + + 'in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no ' + + 'longer renames `triggers[].interval` — the whole array that key lived in is removed, so an ' + + 'author holding either spelling ends with no key at all.', + acceptanceCriteria: 'No connector and no stack connector entry carries `triggers` in any ' + + 'spelling; the parse refuses the key with its prescription, and no code imports ' + + 'ConnectorTrigger or ConnectorTriggerSchema. Every connector registers and dispatches its ' + + 'actions exactly as it did before the upgrade. Each connector trigger the author still ' + + 'wants is a flow that is observed running: a scheduled pull as a `schedule` flow whose ' + + '`connector_action` node calls the connector\'s action at the intended cadence in seconds, ' + + 'and an external event as an `api` flow observed starting when the sender calls it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 96e5e872b73..010f2f0f8e2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5149,9 +5149,29 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them ' + 'would start deliveries that never happened). It ABSORBS the breaker half of the duration ' + 'rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer ' - + 'converted, because the whole block it lived in is now removed, and ' - + '`connector-health-and-trigger-durations-unit-in-key` keeps only `triggers[].interval` → ' - + '`intervalSeconds`.', + + 'converted, because the whole block it lived in is now removed.', + }, + { + id: 'connector-triggers-retired', + order: 48, + text: + 'It also retires the connector `triggers` array (ADR-0049 enforce-or-remove; ADR-0041 keeps ' + + 'connector-event triggers in its third tier, as their own trigger package): the ' + + '`ConnectorTrigger` shape — `key`, `label`, `description`, `type` (`polling` / `webhook`) and ' + + '`intervalSeconds` — was read by nothing. The automation engine registered a connector\'s ' + + 'actions only, its trigger registry holds FLOW trigger kinds that no connector trigger ever ' + + 'entered, no polling loop read an interval and no receiver was driven by a `webhook` trigger, ' + + 'so a declared trigger never started a flow. `triggers` is a retiredKey tombstone on ' + + '`ConnectorBaseSchema`, registered under both carrier defs; the provider-bound refusal of ' + + 'the key, whose reason (the provider derives triggers) was untrue, is gone with it, since ' + + 'the tombstone refuses every value on every carrier. `ConnectorTrigger` leaves whole, and ' + + 'the D2 conversion `connector-triggers-removed` strips the array from `connectors[]` and ' + + 'stored rows as a pure lossless delete — never turning a trigger into a flow, which is the ' + + 'author\'s decision (an `api` flow for an external event, a `schedule` flow for a scheduled ' + + 'pull, each calling the connector\'s action). It ABSORBS the trigger half of the connector ' + + 'duration rename (its breaker half went with `health` above), so ' + + '`connector-health-and-trigger-durations-unit-in-key`, with neither half left, is no longer ' + + 'in this step.', }, { id: 'cron-positions-deleted', @@ -5256,7 +5276,9 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'semantic entry each, naming the suffixed key. The `data`, `ui`, `ai` and ' + '`integration` remainder closes the same sweep: `dashboard.refreshInterval` → ' + '`refreshIntervalSeconds`, the connector pair `health.circuitBreaker.monitoringWindow` ' - + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds`, and the two ' + + '→ `monitoringWindowMs` and `triggers[].interval` → `intervalSeconds` (both halves later ' + + 'absorbed by the removal of the block each key lived in — see the connector retirements ' + + 'below), and the two ' + 'datasource config keys `memory config.persistence.autoSaveInterval` → ' + '`autoSaveIntervalMs` (BOTH union arms — the `auto` arm forwards the same value to the ' + 'same file adapter, so splitting them would have left one value with two spellings) ' @@ -7777,49 +7799,6 @@ const step18: MigrationStep = { + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + 'both resolve to real reads at the fetch site.', }, - // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its - // unit in its NAME) — the D3 entry of the - // `connector-health-and-trigger-durations-unit-in-key` family (ruling B on - // #17152: one D3 entry per retirement family, even when D2 is lossless). The - // family was two keys in one authored document and one conversion: - // `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and - // `triggers[].interval` → `intervalSeconds`. - // - // ⚠️ Reconciled with the connector resilience retirement (ADR-0049, the same - // unreleased protocol step): the whole `health` block was then removed, so the - // breaker half of this rename was ABSORBED — the renamed key is itself retired, - // and the conversion now carries only the trigger half. This entry says so, - // rather than prescribing a rename to a key the parse refuses next; the - // removal's own judgement is the D3 entry `connector-resilience-keys-retired`. - // `triggers[].interval` is still unread (the liveness ledger records it dead, - // `liveness/connector.json`): the rename is an honesty fix to the declaration, - // and the entry says so rather than implying a live engine. - { - id: 'connector-resilience-durations-unit-in-key', - surface: 'connector.triggers[].interval — the connector duration whose name carried no unit ' - + '(and, until the whole `health` block was retired, connector.health.circuitBreaker.monitoringWindow)', - replacement: '`intervalSeconds` (seconds) — rename the key; the value is unchanged. There is no ' - + 'replacement for `monitoringWindow`: its renamed spelling `monitoringWindowMs` was retired with ' - + 'the rest of `connector.health` — delete the block (see `connector-resilience-keys-retired`).', - reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames ' - + '`triggers[].interval` in `connectors[]` and on stored connector rows, keeping the value; the ' - + 'rename is lossless because the key always meant seconds. It used to rename the breaker\'s ' - + '`monitoringWindow` too, but that half was absorbed by `connector-resilience-keys-removed`, ' - + 'which strips the whole `health` block — so an author holding either `monitoringWindow` or ' - + '`monitoringWindowMs` ends with no key at all, and must not re-add `monitoringWindowMs`: the ' - + 'parse refuses the block. Two judgments remain for the trigger. First, the unit was easy to ' - + 'get wrong: the bare token `interval` means MILLISECONDS elsewhere in this same spec while a ' - + 'trigger interval meant SECONDS — so a trigger written `interval: 60000` for one minute asked ' - + 'for once every sixteen hours or so, and the rename keeps 60000. Second, the key drives no ' - + 'engine today: no polling loop reads a trigger interval, so an author who relied on it for ' - + 'behaviour has not been getting it, before or after this rename.', - acceptanceCriteria: 'No connector carries `triggers[].interval`; the parse refuses it with the ' - + 'rename, and every `intervalSeconds` value is the cadence the author intends in seconds — a ' - + 'trigger meant to poll every minute reads `intervalSeconds: 60`. No connector carries ' - + '`health` in any spelling (`monitoringWindow` or `monitoringWindowMs` included). No part of ' - + 'the deployment\'s design depends on a connector polling on that interval or tripping on a ' - + 'breaker window: where it did, the author has moved that need to a mechanism that runs.', - }, // ADR-0049 enforce-or-remove — the D3 entry of the connector resilience family: // `connector.health` (the `healthCheck` probe and the `circuitBreaker`), // `connector.status` and the connector-nested `webhooks`, sixteen authorable keys @@ -7829,7 +7808,9 @@ const step18: MigrationStep = { // what only the author can judge. It also names the CHAIN through the same // protocol step: `connector-health-and-trigger-durations-unit-in-key` used to // rename `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`, and - // that half was absorbed here — the renamed key is itself removed. + // that half was absorbed here — the renamed key is itself removed. (Its trigger + // half was absorbed later by `connector-triggers-removed`, and the conversion + // left the table.) { id: 'connector-resilience-keys-retired', surface: 'connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — ' @@ -7856,8 +7837,10 @@ const step18: MigrationStep = { + 'counterpart there. The chain: in this same protocol step, ' + '`connector-health-and-trigger-durations-unit-in-key` no longer renames ' + '`health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that ' - + 'key lived in is removed, so an author holding either spelling ends with no key at all; ' - + 'that conversion\'s `triggers[].interval` to `intervalSeconds` rename is unaffected.', + + 'key lived in is removed, so an author holding either spelling ends with no key at all. ' + + 'That conversion\'s other half, `triggers[].interval` to `intervalSeconds`, was absorbed ' + + 'the same way by the removal of the whole `triggers` array (`connector-triggers-removed`), ' + + 'so the rename itself is no longer in the step.', acceptanceCriteria: 'No connector and no stack connector entry carries `health`, `status` or ' + '`webhooks`; the parse refuses each with its prescription (a stored `status: \'inactive\'` ' + 'default is accepted and stripped as inert residue), and no code imports ConnectorHealth, ' @@ -7870,6 +7853,57 @@ const step18: MigrationStep = { + 'probe or breaker the author relied on is provided by the connector provider or a gateway ' + 'and observed tripping against a failing upstream.', }, + // ADR-0049 enforce-or-remove — the D3 entry of the connector triggers family: + // `connector.triggers`, the whole `ConnectorTrigger` array, retired as one + // batch by ruling (ADR-0041 unchanged: connector-event triggers stay in its + // third tier, as their own trigger package). One D3 entry per retirement + // family, even when D2 is lossless (ruling B on #17152): the D2 conversion + // `connector-triggers-removed` repairs the data, and this entry carries what + // only the author can judge. It also names the CHAIN through the same protocol + // step: `connector-health-and-trigger-durations-unit-in-key` used to rename + // `triggers[].interval` to `intervalSeconds`; that half was absorbed here (the + // breaker half already was, by the `health` removal), so the rename left the + // table, and this family's former rename entry left with it. + { + id: 'connector-triggers-retired', + surface: 'connector.triggers — the ConnectorTrigger array (key / label / description / type / ' + + 'intervalSeconds, and the interval spelling it was renamed from), on a connector and on a ' + + 'stack connectors[] entry', + replacement: '(removed — nothing replaces a connector trigger.) Start the work from a flow that ' + + 'calls the connector\'s action in a `connector_action` node: an external event starts an ' + + '`api` flow that the event\'s sender calls, and a scheduled pull is a `schedule` flow.', + reason: 'The D2 conversion `connector-triggers-removed` deletes `triggers` from every connector, ' + + 'stack entry and stored connector row, one notice per connector, and the delete is lossless: ' + + 'the automation engine registered a connector\'s actions only, no polling loop read an ' + + 'interval, no receiver was driven by a `webhook` trigger, and no provider derived one — so a ' + + 'declared trigger never started a flow, before or after the upgrade. Three judgements ' + + 'remain. First, any part of the deployment designed around a connector trigger firing has ' + + 'never been running, so the author decides which of those triggers should now exist as ' + + 'flows: a `polling` trigger becomes a `schedule` flow whose `connector_action` node calls ' + + 'the connector\'s read action, and a `webhook` trigger becomes an `api` flow that the ' + + 'external sender calls. The conversion STRIPS the array and never writes a flow, because ' + + 'a flow that runs STARTS work that never happened before — its cadence, its action and ' + + 'what it does with the result are the author\'s. Second, a polling cadence is in SECONDS: ' + + 'the key was renamed from `interval` to `intervalSeconds` earlier in this same protocol ' + + 'step because the bare `interval` means milliseconds elsewhere in this spec, so a trigger ' + + 'written `interval: 60000` for one minute asked for once every sixteen hours or so — carry ' + + 'the intended cadence, not the stored number, into the schedule. Third, turning a ' + + '`webhook` trigger into an `api` flow opens an inbound endpoint that never existed before ' + + '(the trigger declared no receiver and no verification), and the platform refuses an `api` ' + + 'flow with no per-flow secret and verifies a signature on every call — so whether the ' + + 'external sender can sign its calls decides whether that flow can receive them directly. ' + + 'The chain: ' + + 'in this same protocol step, `connector-health-and-trigger-durations-unit-in-key` no ' + + 'longer renames `triggers[].interval` — the whole array that key lived in is removed, so an ' + + 'author holding either spelling ends with no key at all.', + acceptanceCriteria: 'No connector and no stack connector entry carries `triggers` in any ' + + 'spelling; the parse refuses the key with its prescription, and no code imports ' + + 'ConnectorTrigger or ConnectorTriggerSchema. Every connector registers and dispatches its ' + + 'actions exactly as it did before the upgrade. Each connector trigger the author still ' + + 'wants is a flow that is observed running: a scheduled pull as a `schedule` flow whose ' + + '`connector_action` node calls the connector\'s action at the intended cadence in seconds, ' + + 'and an external event as an `api` flow observed starting when the sender calls it.', + }, { id: 'cube-join-sql-and-relationship-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code @@ -19343,7 +19377,8 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `monitoringWindow` spelling was retired. The rename's breaker half was absorbed // by `connector-resilience-keys-removed`, which strips the block an author // holding either spelling still carries; the `health` tombstone's prescription - // names both spellings. + // names both spellings. (Its trigger half was absorbed later in the same step by + // `connector-triggers-removed`, so the rename conversion itself left the table.) 'integration/CircuitBreakerConfig:monitoringWindow', // ADR-0049 enforce-or-remove on `ConnectorSchema.connectionTimeoutMs` // (maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this @@ -19531,6 +19566,37 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // refusal. Sources and stored rows are rewritten by the D2 conversion // `connector-resilience-keys-removed`. 'integration/Connector:status', + // ADR-0049 enforce-or-remove on `ConnectorSchema.triggers` — the connector + // triggers family, ruled RETIRE on the maintainer's criterion for a + // declared-but-unenforced family, with ADR-0041 left as it is (connector-event + // triggers stay in its third tier, as their own trigger package, promoted only + // when real projects ask for them). The `ConnectorTrigger` array (`key`, + // `label`, `description`, `type: 'polling' | 'webhook'`, `intervalSeconds`) was + // read by NOTHING. Measured on `origin/main` before the removal: + // `AutomationEngine.registerConnector` walks `parsed.actions` only and stores the + // rest of the def unread; the engine's trigger registry is keyed by FLOW trigger + // kind (`record_change`, `time_relative`, `schedule`, `api` — a closed set) and + // no connector trigger ever entered it; no polling loop read `intervalSeconds`; + // no receiver was driven by a `webhook` trigger; and across every `.ts`, `.tsx` + // and `.json` file outside `packages/spec` (tests excluded) at `288611e3e5`, the + // 15 authorings of a `triggers:` key were all webhook `triggers`, a plugin + // grouping or form-label translations — none a connector trigger — while + // `actions`, the lit control on the same def, is walked by `registerConnector` + // and handler-checked there. + // + // The one runtime touch was a REFUSAL of `triggers` on a provider-bound + // declarative instance, reasoned "the provider derives them from the upstream at + // boot" — untrue, since no provider ever derived a trigger. The tombstone makes + // that rule unreachable (every carrier refuses every value), so the rule left + // with it rather than being re-reasoned. + // + // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned + // `integration/ConnectorTrigger` leaves via `RETIRED_DEFS_BY_MAJOR[18]`. The key + // carried no default, so no retired-default residue is owed. Sources and stored + // rows are rewritten by the D2 conversion `connector-triggers-removed`, which + // STRIPS the array and never turns a trigger into a flow — that is the author's + // decision, carried by the D3 entry `connector-triggers-retired`. + 'integration/Connector:triggers', // ADR-0049 enforce-or-remove on `ConnectorSchema.webhooks` — part of the // connector resilience family batch (see `18.integration__Connector__health.ts`). // A connector's NESTED webhook array is not the collection anything delivers: @@ -19562,9 +19628,17 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // value is unchanged. Tombstoned with `retiredKey()`; the shape is not // `.strict()`, so a bare deletion would strip in silence. Covered by the D2 // conversion `connector-health-and-trigger-durations-unit-in-key`. - // ⚠️ The trigger shape itself is declared-but-unread (no polling loop is driven - // by it). The rename does not change that; it makes the declaration honest - // about its unit for whoever implements the loop. + // + // ⚠️ Superseded in the same unreleased step: the trigger shape was declared but + // never read (no polling loop was driven by it), and the whole `triggers` array + // was then retired under ADR-0049 — `integration/ConnectorTrigger` left whole + // (`RETIRED_DEFS_BY_MAJOR[18]`) and this tombstone left with it. The row STAYS — + // the whole-def removal steady state gate (b3) exempts — because it is still the + // record that the bare `interval` spelling was retired (the + // `integration/CircuitBreakerConfig:monitoringWindow` precedent). The rename + // conversion left the table, both of its halves absorbed; the trigger half by + // `connector-triggers-removed`, which strips the array an author holding either + // spelling still carries, and the `triggers` tombstone's prescription names both. 'integration/ConnectorTrigger:interval', // The same tombstone seen through the second carrier. // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are now SIBLINGS, not @@ -19618,6 +19692,12 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // tombstone, two registered keys, EXACT per-def membership (gate (b)). See // `18.integration__Connector__status.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:status', + // The same `triggers` tombstone seen through the second carrier — the shape + // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, where the + // provider-bound refusal the tombstone replaced used to live. One tombstone, two + // registered keys, EXACT per-def membership (gate (b)). See + // `18.integration__Connector__triggers.ts` for the retirement record. + 'integration/DeclarativeConnectorEntry:triggers', // The same `webhooks` tombstone seen through the second carrier — the shape // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. One // tombstone, two registered keys, EXACT per-def membership (gate (b)). See @@ -23085,6 +23165,15 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // one. See `retired-keys/18.integration__Connector__status.ts` for the // retirement record. 'integration/ConnectorStatus', + // `integration/ConnectorTrigger` (`key`, `label`, `description`, + // `type: 'polling' | 'webhook'`, `intervalSeconds`, and the `interval` tombstone + // of its unit rename) leaves with its only carrier, `ConnectorSchema.triggers`, + // tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a + // connector trigger, so nothing replaces the shape: work starts from a flow that + // calls the connector's action. See + // `retired-keys/18.integration__Connector__triggers.ts` for the retirement + // record. + 'integration/ConnectorTrigger', // #14676 — `integration/ErrorMappingConfig` (`rules`, `defaultCategory`, // `unmappedBehavior`, `logUnmapped`) leaves with its only carrier: // `ConnectorSchema.errorMapping`, tombstoned in this same major under ADR-0049 diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 0fca2f78833..068c6a76fe9 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 780 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 779 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -933,12 +933,12 @@ export type Iso_identity_scim__SCIMPatchOperationSchema = Assert, z.infer< typeof M78.ConnectorActionEffectSchema > >>; export type Iso_integration_connector__ConnectorActionSchema = Assert, z.infer< typeof M78.ConnectorActionSchema > >>; export type Iso_integration_connector__ConnectorConflictResolutionSchema = Assert, z.infer< typeof M78.ConnectorConflictResolutionSchema > >>; export type Iso_integration_connector__ConnectorRetryStrategySchema = Assert, z.infer< typeof M78.ConnectorRetryStrategySchema > >>; -export type Iso_integration_connector__ConnectorTriggerSchema = Assert, z.infer< typeof M78.ConnectorTriggerSchema > >>; export type Iso_integration_connector__ConnectorTypeSchema = Assert, z.infer< typeof M78.ConnectorTypeSchema > >>; export type Iso_integration_connector__SyncStrategySchema = Assert, z.infer< typeof M78.SyncStrategySchema > >>; @@ -1666,7 +1666,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 780 isomorphic pins', () => { + it('still declares all 779 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2364,7 +2364,16 @@ describe('ADR-0122 type-alias convention', () => { // `ElementDefinitionListPropsSchema` pin above, so this entry's arrow // starts from 781. The count below was re-derived from the merged file, // not added up. - expect(pins).toHaveLength(780); + // + // 780 -> 779 is the ADR-0049 retirement of the connector `triggers` array: + // `ConnectorTriggerSchema` left whole with its carrier key (whole-def + // removal, `RETIRED_DEFS_BY_MAJOR[18]`), so its one M78 pin + // (Iso_integration_connector__ConnectorTriggerSchema) leaves with the + // schema. It carried no `XParsed` alias — its only tombstone, the + // `interval` rename's `retiredKey()`, kept input and output equal — so the + // pin was the whole of its footprint here. The M78 slot stays occupied by + // the module's surviving pins. -1 removed. + expect(pins).toHaveLength(779); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 4a51683aa2b..368f14b9eae 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -32,6 +32,7 @@ "src/identity/position-delegatable-enforcer.pin.test.ts", "src/integration/connector-connection-timeout-retirement.test.ts", "src/integration/connector-resilience-keys-retirement.test.ts", + "src/integration/connector-triggers-retirement.test.ts", "src/qa/requires-plugins-retirement.test.ts", "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts",