diff --git a/.changeset/19580-retire-connector-connection-timeout-ms.md b/.changeset/19580-retire-connector-connection-timeout-ms.md new file mode 100644 index 00000000000..8252183817a --- /dev/null +++ b/.changeset/19580-retire-connector-connection-timeout-ms.md @@ -0,0 +1,133 @@ +--- +'@objectstack/spec': minor +'@objectstack/connector-rest': patch +'@objectstack/connector-openapi': patch +'@objectstack/connector-mcp': patch +'@objectstack/connector-slack': patch +'@objectstack/service-automation': patch +--- + +feat(spec)!: retire `connector.connectionTimeoutMs` — declared, bounded, defaulted, served back, and never applied as a deadline + +**BREAKING** — `connector.connectionTimeoutMs` is removed. ADR-0049 +enforce-or-remove; maintainer ruling 2026-09-22, letter A. It is the narrower +**second** decision this key was owed: the earlier ruling that made its nine +liveness siblings live (`retryConfig.*`, `requestTimeoutMs`) left this one dead +on a stated reason rather than by oversight, and `packages/spec/liveness/connector.json` +has been asking for this decision since. + +The key was bounded (`min(1000).max(300000)`), defaulted (`30000`), +`.describe()`d, authorable on both carriers and served back by +`/meta/connector`. Every signal an authoring surface can give said it worked. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `connector.connectionTimeoutMs` (on `Connector` and on `DeclarativeConnectorEntry`, so `stack.connectors[]` and `PUT /meta/connector/:name`) | `requestTimeoutMs` — the deadline the platform keeps, applied as `resilientFetch`'s per-attempt timeout. For a connect-only bound, configure it at a connector provider or upstream gateway on a transport that can separate the phases. | +| `ConnectorProviderContext.connectionTimeoutMs` (handed to every `ConnectorProviderFactory`) | `ctx.requestTimeoutMs`, or the factory's own `providerConfig` where the provider owns the vocabulary. | +| The `ZodObject` combinators on `ConnectorSchema` and `DeclarativeConnectorEntrySchema` — `.extend()`, `.omit()`, `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()`, `.safeExtend()` | Both exports are now `z.preprocess` **pipes** (the residue stage below), so those methods no longer exist on them. **Build on the object and re-wrap:** `acceptRetiredDefaultResidue(, { connectionTimeoutMs: 30000 })`, the `EffectiveObjectPermissionSchema` route. ⚠️ `.superRefine()` still *exists* on a pipe but returns a schema with no read-through `shape`, so refine before wrapping, not after. Parsing, `z.input` / `z.infer`, and the read-through `.shape` are unchanged. | + +**The one-line fix: delete the key** — and, for a custom provider factory, stop +reading `ctx.connectionTimeoutMs`. `os migrate meta --from 17` lists the +mechanical edits for existing sources; apply them by hand. + +⚠️ Runtime behaviour is **unchanged for every shipped provider**, because none +ever applied the value: a connector that authored `connectionTimeoutMs: 1000` +made exactly the same calls, with exactly the same deadlines, as one that did +not. What does change is observable and intended: the def served by +`GET /connectors` no longer echoes a connect deadline nobody keeps. + +### ⭐ This is NOT the zero-mention retirement shape + +Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at +`origin/main`: **thirteen** non-test source occurrences over seven files in five +packages — **six reads** (`openapi-connector.ts:242`, `openapi-provider.ts:193`, +`rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, +`plugin.ts:1589`), **four type declarations**, and **three** surviving hardcoded +`30000` writes. Reading the retirement as "nothing referenced it" loses the +finding. Measured across all six reads, every one is a **pass-through**: the +value's only termini were the def `GET /connectors` echoes and the fingerprint +that decides whether to re-materialize. `connectorFetchOptions()` — the one +mapping from authored policy onto the platform's outbound `fetch` — was handed +`{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring it, +and ADR-0049 forbids the parsed-unmarked-unenforced state whether the inert +value travels or sits still. + +Nor was the `实现` arm available. A connector's outbound call is a WHATWG +`fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole +operation; nothing in that interface observes the connection phase. Bounding +"time until the response arrives" with this key would kill a slow-but-connected +upstream the author meant to allow with a large `requestTimeoutMs` — breaking +the very promise the key makes. (undici's `connectTimeout` needs a custom +dispatcher: Node-only, and a new subsystem underneath every connector, which the +ruling that made the siblings live forbids.) + +### The retirement kit + +- The **authorable key** is a `retiredKey()` tombstone on `ConnectorSchema`, + registered as `integration/Connector:connectionTimeoutMs` and + `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in + `RETIRED_KEYS_BY_MAJOR[18]`. The schema is not `.strict()`, so a bare deletion + would strip an authored key in silence (ADR-0104): the tombstone is audible in + both channels — `tsc` (input type `never`) and the parse, which raises the + prescription itself. `DeclarativeConnectorEntrySchema` carries it too — both + published carriers wrap the same private `ConnectorBaseSchema` — so + `stack.connectors[]` and the `/meta/connector` door refuse it too. +- **A D2 conversion, `connector-connection-timeout-ms-removed`** — one strip per + `connectors[]` entry, a pure lossless delete. ⭐ The ruling left whether one was + owed to be **measured** ("a D2 conversion only if a stored connector row can + carry the key"). It can, and both legs were measured before the tombstone + landed: `getMetadataTypeSchema('connector')` — what `PUT /meta/connector/:name` + validates against — parsed a body carrying the key and its output **retained** + the authored value, so the number reached `sys_metadata`; and + `applyConversionsToStoredItem('connector', …)` is live for this type. Rows + written on 17.x therefore replay clean. +- **A D3 semantic entry, + `connector-provider-context-connection-timeout-ms-retired`**, for the withdrawn + `ConnectorProviderContext` member. A provider factory is code: there is no + authored source and no `sys_metadata` row for a conversion to rewrite, so the + removal reaches a factory author as a `tsc` error and as that entry. +- **No def leaves.** The key was a bare `z.number()`, never a `ConfigSchema` + shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing — and `api-surface/` and + `json-schema.manifest/` are byte-identical, which is the correct reading for a + key-only tombstone rather than a missed regeneration. +- `authorable-surface/integration.json` gains two `[RETIRED]` rows; + `authorable-defaults/integration.json` loses the two `= 30000` rows. +- The liveness row **stays** `dead` with a `REMOVED` note, because `retiredKey()` + keeps the key in the walked shape. Its previous note claimed "every occurrence + outside `packages/spec` is a WRITE". That reading was **correct at the SHA the + card cited and dated** (`0870fb5418` — exactly five non-spec source hits, all + five `connectionTimeoutMs: 30000,`) and was superseded by `b929e0a662`, the PR + the card itself flagged as pending. It is **stale, not false**, and the row now + carries both readings with their trees rather than one undated claim. +- **An `acceptRetiredDefaultResidue` stage** (#12840), `{ connectionTimeoutMs: 30000 }` + on both carriers. The key was `.optional().default(30000)`, so a 17.x parse + materialized it into **every** connector — measured across two builds: the base + build emits it for an entry that authored only `name`/`label`/`type`, and the + tombstoned build refuses that exact object at `connectors.0.connectionTimeoutMs`. + The D2 does **not** discharge the obligation, and the precedent shows it: + `ObjectPermission:allowPurge` carries a D2 **and** the residue stage, for its + own reason (a released toolchain materialized its default into every built + artifact's entries). The reason *here* is a different one — this schema has a + second door: `AutomationEngine.registerConnector` parses `ConnectorSchema` for + a def a plugin or provider factory builds **in code**, where no conversion + ever runs, and all four shipped connector packages put the materialized value + straight into that def literal. So the emitted `30000` + is accepted-and-stripped while `15000` keeps the tombstone's refusal, and + nothing is un-retired: `z.input` stays `never` and the `[RETIRED]` row stays. +- **No deprecation window** (maintainer 2026-08-27: 「项目在创业阶段,用户也很少,短期不考虑渐进」), + and no staged retirement. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no download, dependent or source +telemetry was consulted for. The pinned sibling checkout **was** measured: zero +occurrences of the name at objectui `87af769e`, against a lit control on the same +command and scope, so no sibling fix or pin bump rides with this. + +`Clause-②: yes (narrowing)` — a published authorable key is removed on two +carriers and a published interface member leaves `ConnectorProviderContext`, so +the accept set a consumer writes against narrows. Nothing is widened and nothing +is renamed. Contract-review tier. + + diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 32d98968500..f8dccb6e1fa 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -55,17 +55,20 @@ construction. ⚠️ Retrying a `429` is not throttling it: a retry policy spaces out the calls you already made, it does not cap the rate, so the sentence above about rate limiting stands unchanged. -⛔ **Two exceptions, both still inert and both still `dead` in -`packages/spec/liveness/connector.json`.** `health.circuitBreaker`: every -sub-key is unread and no breaker ever opens — implement circuit breaking in -the connector provider. `connectionTimeoutMs`: it is carried to a provider -factory but the platform does not enforce it, because a WHATWG `fetch` +⛔ **One exception remains, still inert and still `dead` in +`packages/spec/liveness/connector.json`:** `health.circuitBreaker` — every +sub-key is unread and no breaker ever opens; implement circuit breaking in +the connector provider. + +`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 +it is not implementable where it was declared, because a WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never the connection -phase alone; `requestTimeoutMs` is the bound the platform can keep, and -ADR-0049 owes this one key a narrower decision. The full removal reasoning -for the rate-limit shape is recorded at the removal site: the "REMOVED: -outbound rate limiting" block in `integration/connector.zod.ts`, and -`packages/spec/docs/SYNC_ARCHITECTURE.md`. +phase alone. `requestTimeoutMs` is the bound the platform can keep. The full +removal reasoning is recorded at each removal site: the "REMOVED: +`connectionTimeoutMs`" and "REMOVED: outbound rate limiting" blocks in +`integration/connector.zod.ts`, and `packages/spec/docs/SYNC_ARCHITECTURE.md`. **Field mapping does not transform values.** This header used to offer "field mapping and transformations"; only the first half was ever true. @@ -200,7 +203,7 @@ Circuit breaker configuration | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **retryConfig** | `{ strategy: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts: number; initialDelayMs: number; maxDelayMs: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | +| **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | | **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | @@ -684,7 +687,7 @@ Connector type | **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | | **rateLimitConfig** | `never` | optional | [REMOVED] `connector.rateLimitConfig` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — the entire shape is gone, not just this key: `ConnectorRateLimitConfig` and its `RateLimitStrategy` enum were removed with it, because no outbound rate-limiting engine ever existed. The platform's only token bucket (runtime `security/rate-limit.ts`) throttles INBOUND requests to us; nothing throttled the calls a connector makes out, so every knob here was inert while reading like a configured cap. Delete the key. Do NOT substitute `shared` `RateLimitConfig` — that is the inbound limiter and would cap the wrong direction; until an outbound throttle exists, rate-limit at the connector provider or upstream gateway. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **retryConfig** | `{ strategy: Enum<'exponential_backoff' \| 'linear_backoff' \| 'fixed_delay' \| 'no_retry'>; maxAttempts: number; initialDelayMs: number; maxDelayMs: number; … }` | optional | Retry configuration | -| **connectionTimeoutMs** | `number` | optional (default: `30000`) | Connection timeout in ms | +| **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the platform never honoured it and cannot honour it where it was declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` over the whole operation, so nothing there observes the connection phase separately, and the value only ever travelled (onto the reported def and the materialization fingerprint) without ever bounding a connect. Delete the key. Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as `resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **requestTimeoutMs** | `number` | optional (default: `30000`) | Request timeout in ms | | **status** | `Enum<'active' \| 'inactive' \| 'error' \| 'configuring'>` | optional (default: `"inactive"`) | Connector status | | **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | diff --git a/packages/connectors/connector-mcp/src/mcp-connector.ts b/packages/connectors/connector-mcp/src/mcp-connector.ts index e0d508510ee..e0dd0dc5135 100644 --- a/packages/connectors/connector-mcp/src/mcp-connector.ts +++ b/packages/connectors/connector-mcp/src/mcp-connector.ts @@ -244,7 +244,9 @@ export async function createMcpConnector(opts: McpConnectorOptions): Promise ({ key: tool.name, diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts index a0172843ccb..f221d8d8706 100644 --- a/packages/connectors/connector-openapi/src/openapi-connector.ts +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -127,12 +127,12 @@ export interface OpenApiConnectorConfig { * (ADR-0049 · #18975). Omitted ⇒ the wrapper's own defaults. */ retryConfig?: RetryConfig; - /** - * Declared connect deadline (ms). Carried onto the def so `GET /connectors` - * reports what the author declared; ⚠️ not enforced — one `fetch` signal - * cannot bound the connection phase alone (`connector-fetch-policy.ts`). - */ - connectionTimeoutMs?: number; + // `connectionTimeoutMs` — REMOVED with the spec key (ADR-0049). It was + // accepted here only to be carried onto the def `GET /connectors` echoes: + // one `fetch` signal cannot bound the connection phase alone, so it never + // reached `connectorFetchOptions` and never bounded a call. Echoing a + // deadline nobody keeps is what the retirement withdraws (mirrors + // connector-rest). /** Per-request deadline (ms) — `resilientFetch`'s per-attempt timeout. */ requestTimeoutMs?: number; /** Injected fetch implementation (defaults to global `fetch`). */ @@ -239,7 +239,6 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC // the (post-parse) Connector output type (mirrors connector-rest/mcp). status: 'active', enabled: true, - connectionTimeoutMs: config.connectionTimeoutMs ?? 30000, requestTimeoutMs: config.requestTimeoutMs ?? 30000, ...(config.retryConfig === undefined ? {} : { retryConfig: config.retryConfig }), actions, diff --git a/packages/connectors/connector-openapi/src/openapi-provider.ts b/packages/connectors/connector-openapi/src/openapi-provider.ts index 9691be8112c..0abec68b4f1 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.ts @@ -190,7 +190,6 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. retryConfig: ctx.retryConfig, - connectionTimeoutMs: ctx.connectionTimeoutMs, requestTimeoutMs: ctx.requestTimeoutMs, fetchImpl: deps.fetchImpl, }); diff --git a/packages/connectors/connector-rest/src/rest-connector.ts b/packages/connectors/connector-rest/src/rest-connector.ts index 8d669ff348e..c409d209299 100644 --- a/packages/connectors/connector-rest/src/rest-connector.ts +++ b/packages/connectors/connector-rest/src/rest-connector.ts @@ -39,12 +39,12 @@ export interface RestConnectorOptions { * (ADR-0049 · #18975). Omitted ⇒ the wrapper's own defaults. */ retryConfig?: RetryConfig; - /** - * Declared connect deadline (ms). Carried onto the def so `GET /connectors` - * reports what the author declared; ⚠️ not enforced — one `fetch` signal - * cannot bound the connection phase alone (`connector-fetch-policy.ts`). - */ - connectionTimeoutMs?: number; + // `connectionTimeoutMs` — REMOVED with the spec key (ADR-0049). It was + // accepted here only to be carried onto the def `GET /connectors` echoes: + // one `fetch` signal cannot bound the connection phase alone, so it never + // reached `connectorFetchOptions` and never bounded a call. Echoing a + // deadline nobody keeps is what the retirement withdraws. A connect-only + // bound belongs to a transport that can observe the connect phase. /** Per-request deadline (ms) — `resilientFetch`'s per-attempt timeout. */ requestTimeoutMs?: number; /** Injected for tests; defaults to the global `fetch`. */ @@ -131,7 +131,6 @@ export function createRestConnector(opts: RestConnectorOptions): RestConnectorBu // the (post-parse) Connector output type. status: 'active', enabled: true, - connectionTimeoutMs: opts.connectionTimeoutMs ?? 30000, requestTimeoutMs: opts.requestTimeoutMs ?? 30000, ...(opts.retryConfig === undefined ? {} : { retryConfig: opts.retryConfig }), actions: [ diff --git a/packages/connectors/connector-rest/src/rest-provider.test.ts b/packages/connectors/connector-rest/src/rest-provider.test.ts index 15ee09a0fbd..296109f7201 100644 --- a/packages/connectors/connector-rest/src/rest-provider.test.ts +++ b/packages/connectors/connector-rest/src/rest-provider.test.ts @@ -227,18 +227,22 @@ describe('rest provider factory (ADR-0097)', () => { expect(calls).toBe(1); }); - it('carries the declared timeouts onto the def it registers', async () => { + it('carries the declared request timeout onto the def it registers', async () => { const { impl } = stubFetch(); const factory = createRestProviderFactory({ fetchImpl: impl }); const { def } = await factory( ctx({ providerConfig: { baseUrl: 'https://api.example.com' }, - connectionTimeoutMs: 5000, requestTimeoutMs: 7000, }), ); - expect(def.connectionTimeoutMs).toBe(5000); expect(def.requestTimeoutMs).toBe(7000); + // `connectionTimeoutMs` was the second half of this pin and is + // RETIRED (ADR-0049): the factory never applied it, it only echoed + // it back onto the def. The absence pin lives tree-scoped in + // `packages/spec/src/integration/connector-connection-timeout-retirement.test.ts`; + // here the point is only that the surviving timeout still travels. + expect(Object.keys(def)).not.toContain('connectionTimeoutMs'); }); }); }); diff --git a/packages/connectors/connector-rest/src/rest-provider.ts b/packages/connectors/connector-rest/src/rest-provider.ts index 51544e13845..56aa358ba7c 100644 --- a/packages/connectors/connector-rest/src/rest-provider.ts +++ b/packages/connectors/connector-rest/src/rest-provider.ts @@ -61,7 +61,6 @@ export function createRestProviderFactory(deps: RestProviderDeps = {}): Connecto // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. retryConfig: ctx.retryConfig, - connectionTimeoutMs: ctx.connectionTimeoutMs, requestTimeoutMs: ctx.requestTimeoutMs, fetchImpl: deps.fetchImpl, }); diff --git a/packages/connectors/connector-slack/src/slack-connector.ts b/packages/connectors/connector-slack/src/slack-connector.ts index a682c7f1c71..aea4fb1c779 100644 --- a/packages/connectors/connector-slack/src/slack-connector.ts +++ b/packages/connectors/connector-slack/src/slack-connector.ts @@ -91,7 +91,9 @@ export function createSlackConnector(opts: SlackConnectorOptions): SlackConnecto // the (post-parse) Connector output type. status: 'active', enabled: true, - connectionTimeoutMs: 30000, + // `connectionTimeoutMs` — REMOVED with the spec key (ADR-0049): it was + // written here only so the literal satisfied the post-parse type, and + // the platform never applied it as a connect deadline. requestTimeoutMs: 30000, actions: [ { diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 7bbf70b0cef..26b93d50343 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -842,25 +842,44 @@ describe('ADR-0097 — the entry\'s resilience policy on ConnectorProviderContex await kernel.shutdown(); }); - it('carries both declared timeouts verbatim', async () => { + it('carries the declared request timeout verbatim', async () => { const { factory, calls } = makeFakeProvider(); const kernel = await boot( - [{ ...providerConnector('billing'), connectionTimeoutMs: 5000, requestTimeoutMs: 12000 }], + [{ ...providerConnector('billing'), requestTimeoutMs: 12000 }], { providerFactory: factory }, ); - expect(calls[0]?.connectionTimeoutMs).toBe(5000); expect(calls[0]?.requestTimeoutMs).toBe(12000); await kernel.shutdown(); }); - it('leaves all three undefined when the entry declares none — absence stays absence', async () => { + it('⛔ does NOT carry a stored connectionTimeoutMs onto the context — the carry is retired', async () => { + // ADR-0049: the key was handed to factories as a pure carry and no + // provider ever applied it, so the member left `ConnectorProviderContext` + // with the spec key. A row that still spells it (written before the + // retirement, or by a seam that bypasses the parse) must reach a factory + // with nothing extra — the host does not resurrect the carry. + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [{ ...providerConnector('billing'), connectionTimeoutMs: 5000, requestTimeoutMs: 12000 } as Record], + { providerFactory: factory }, + ); + + expect((calls[0] as unknown as Record).connectionTimeoutMs).toBeUndefined(); + // The lit control on the same context object and the same boot: the + // surviving timeout does arrive, so an empty reading above is the + // retirement and not a dead harness. + expect(calls[0]?.requestTimeoutMs).toBe(12000); + + await kernel.shutdown(); + }); + + it('leaves both undefined when the entry declares none — absence stays absence', async () => { const { factory, calls } = makeFakeProvider(); const kernel = await boot([providerConnector('billing')], { providerFactory: factory }); expect(calls[0]?.retryConfig).toBeUndefined(); - expect(calls[0]?.connectionTimeoutMs).toBeUndefined(); expect(calls[0]?.requestTimeoutMs).toBeUndefined(); await kernel.shutdown(); diff --git a/packages/services/service-automation/src/degraded-register-cause.test.ts b/packages/services/service-automation/src/degraded-register-cause.test.ts index 2f248a85f33..a3bc036454e 100644 --- a/packages/services/service-automation/src/degraded-register-cause.test.ts +++ b/packages/services/service-automation/src/degraded-register-cause.test.ts @@ -98,7 +98,6 @@ function huskDef(name: string): Connector { status: 'error', enabled: true, authentication: { type: 'none' }, - connectionTimeoutMs: 30000, requestTimeoutMs: 30000, actions: [], } as Connector; diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 7a041db8360..695deec8867 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -288,7 +288,6 @@ function connectorInstanceSignature(entry: { icon?: unknown; type?: unknown; retryConfig?: unknown; - connectionTimeoutMs?: unknown; requestTimeoutMs?: unknown; }): string { return stableStringify({ @@ -304,7 +303,12 @@ function connectorInstanceSignature(entry: { // edit to it must re-materialize, exactly like a `providerConfig` edit. // Omitting it here would leave the old policy serving until restart. retryConfig: entry.retryConfig ?? null, - connectionTimeoutMs: entry.connectionTimeoutMs ?? null, + // `connectionTimeoutMs` — REMOVED with the spec key (ADR-0049). It was + // in the fingerprint for the reason above, but it was never a + // materialization input: no provider applied it, so an edit to it + // re-materialized a bundle that behaved identically and only changed the + // number the reported def echoed. Dropping it narrows the fingerprint to + // the inputs that actually change the transport. requestTimeoutMs: entry.requestTimeoutMs ?? null, }); } @@ -332,11 +336,15 @@ interface DeclaredConnectorItem { /** * The entry's declared resilience policy, raw as authored — defaults are * NOT applied here (see the note above), so `retryConfig` is parsed on the - * way onto `ConnectorProviderContext` and the two timeouts are carried + * way onto `ConnectorProviderContext` and `requestTimeoutMs` is carried * verbatim, `undefined` standing for "the author stated nothing". + * + * `connectionTimeoutMs` was a third member and is REMOVED with the spec key + * (ADR-0049): the platform never applied it as a connect deadline, and a + * stored row that still carries it is stripped by the D2 conversion + * `connector-connection-timeout-ms-removed` on rehydration. */ retryConfig?: unknown; - connectionTimeoutMs?: number; requestTimeoutMs?: number; } @@ -1586,7 +1594,6 @@ export class AutomationServicePlugin implements Plugin { // `connectorFetchOptions()` → `resilientFetch()`; a custom // provider doing its own I/O reads it here. retryConfig, - connectionTimeoutMs: entry.connectionTimeoutMs, requestTimeoutMs: entry.requestTimeoutMs, // #3016 — lets a factory dereference relative file refs (e.g. // openapi's `providerConfig.spec: './billing-openapi.json'`), @@ -1779,7 +1786,8 @@ export class AutomationServicePlugin implements Plugin { status: 'error', enabled: true, authentication: { type: 'none' }, - connectionTimeoutMs: 30000, + // `connectionTimeoutMs` — REMOVED with the spec key (ADR-0049): it + // was written here only so the literal satisfied the post-parse type. requestTimeoutMs: 30000, actions: [], }; diff --git a/packages/spec/authorable-defaults/integration.json b/packages/spec/authorable-defaults/integration.json index 2c74177ff77..762e255eca5 100644 --- a/packages/spec/authorable-defaults/integration.json +++ b/packages/spec/authorable-defaults/integration.json @@ -7,7 +7,6 @@ "integration/CircuitBreakerConfig:monitoringWindowMs = 60000", "integration/CircuitBreakerConfig:resetTimeoutMs = 30000", "integration/Connector:authentication = {\"type\":\"none\"}", - "integration/Connector:connectionTimeoutMs = 30000", "integration/Connector:enabled = true", "integration/Connector:requestTimeoutMs = 30000", "integration/Connector:status = \"inactive\"", @@ -20,7 +19,6 @@ "integration/DataSyncConfig:realtimeSync = false", "integration/DataSyncConfig:strategy = \"incremental\"", "integration/DeclarativeConnectorEntry:authentication = {\"type\":\"none\"}", - "integration/DeclarativeConnectorEntry:connectionTimeoutMs = 30000", "integration/DeclarativeConnectorEntry:enabled = true", "integration/DeclarativeConnectorEntry:requestTimeoutMs = 30000", "integration/DeclarativeConnectorEntry:status = \"inactive\"", diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index abb46657470..d2f63a82b2c 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -19,7 +19,7 @@ "integration/Connector:actions", "integration/Connector:auth", "integration/Connector:authentication", - "integration/Connector:connectionTimeoutMs", + "integration/Connector:connectionTimeoutMs [RETIRED]", "integration/Connector:description", "integration/Connector:enabled", "integration/Connector:errorMapping [RETIRED]", @@ -88,7 +88,7 @@ "integration/DeclarativeConnectorEntry:actions", "integration/DeclarativeConnectorEntry:auth", "integration/DeclarativeConnectorEntry:authentication", - "integration/DeclarativeConnectorEntry:connectionTimeoutMs", + "integration/DeclarativeConnectorEntry:connectionTimeoutMs [RETIRED]", "integration/DeclarativeConnectorEntry:description", "integration/DeclarativeConnectorEntry:enabled", "integration/DeclarativeConnectorEntry:errorMapping [RETIRED]", diff --git a/packages/spec/docs/SYNC_ARCHITECTURE.md b/packages/spec/docs/SYNC_ARCHITECTURE.md index 0730c45a472..674da60e3e2 100644 --- a/packages/spec/docs/SYNC_ARCHITECTURE.md +++ b/packages/spec/docs/SYNC_ARCHITECTURE.md @@ -173,14 +173,19 @@ Complete, production-grade integration with external systems. Includes authentic > advice above is unchanged: throttle at the connector provider or upstream > gateway. > -> ⛔ **Two keys on this surface are still inert, and both are still `dead` in -> `packages/spec/liveness/connector.json`.** `health.circuitBreaker`: every -> sub-key is unread and no breaker ever opens — implement circuit breaking in -> the connector provider. `connectionTimeoutMs`: it is carried to a provider -> factory, but the platform does not enforce it, because a WHATWG `fetch` +> ⛔ **One key on this surface is still inert, and still `dead` in +> `packages/spec/liveness/connector.json`:** `health.circuitBreaker` — every +> sub-key is unread and no breaker ever opens; implement circuit breaking in the +> connector provider. +> +> `connectionTimeoutMs` was the second and is **removed** (ADR-0049, the +> narrower second decision it was owed). It was carried to a provider factory +> and echoed back onto the reported def, but never applied as a deadline +> anywhere, and it is not implementable where it was declared: a WHATWG `fetch` > exposes one `AbortSignal` over the whole operation and never the connection -> phase alone — `requestTimeoutMs` is the bound the platform can keep, and -> ADR-0049 owes this one key a narrower decision. +> phase alone. Use `requestTimeoutMs`, the bound the platform can keep, and put +> a connect-only bound in a provider or gateway on a transport that can separate +> the phases. > **Field mapping does not transform values.** The ticked line above used to read > "With transformations and data type conversion". Only the second half was ever @@ -214,7 +219,7 @@ Complete, production-grade integration with external systems. Includes authentic > **The bare `Connector` is the AUTHOR shape.** It is `z.input` of > `ConnectorSchema`, so every key carrying a `.default()` — `enabled`, -> `status`, `connectionTimeoutMs`, `requestTimeoutMs`, all of `syncConfig`'s +> `status`, `requestTimeoutMs`, all of `syncConfig`'s > `strategy` / `direction` / `realtimeSync` / `conflictResolution` / > `batchSize` / `deleteMode`, a mapping's `required` / `syncMode`, a webhook's > `method` / `timeoutMs` / `isActive` / `signatureAlgorithm` — is optional when @@ -338,12 +343,11 @@ const sapConnector: Connector = { }, // `requestTimeoutMs` is each attempt's deadline and is enforced. - // ⛔ `connectionTimeoutMs` is NOT: it is carried to a provider factory, but a - // WHATWG `fetch` exposes one `AbortSignal` over the whole operation and never - // the connection phase alone, so the platform has nowhere to apply it. It - // stays `dead` in `packages/spec/liveness/connector.json` and is owed a - // narrower ADR-0049 decision. - connectionTimeoutMs: 30000, + // ⛔ `connectionTimeoutMs` was here and is REMOVED (ADR-0049): a WHATWG + // `fetch` exposes one `AbortSignal` over the whole operation and never the + // connection phase alone, so the platform had nowhere to apply it and never + // did. Authoring it is now a tsc error and a parse error carrying the + // prescription; bound the connect phase at a provider or gateway. requestTimeoutMs: 60000, status: 'active', enabled: true diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 38c06eb037d..a8bedaedd3d 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -52,7 +52,7 @@ "sites": [ "manifest.actions.element.in", "manifest.actions.element.in.params.element.in", - "manifest.connectors.element", + "manifest.connectors.element.out", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.widgets.element", "manifest.datasets.element", @@ -149,7 +149,7 @@ "data.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "data.options[1].manifest.actions.element.in", "data.options[1].manifest.actions.element.in.params.element.in", - "data.options[1].manifest.connectors.element", + "data.options[1].manifest.connectors.element.out", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.widgets.element", "data.options[1].manifest.datasets.element", @@ -230,7 +230,7 @@ "options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "options[1].manifest.actions.element.in", "options[1].manifest.actions.element.in.params.element.in", - "options[1].manifest.connectors.element", + "options[1].manifest.connectors.element.out", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.widgets.element", "options[1].manifest.datasets.element", @@ -272,7 +272,7 @@ "data.packages.element.options[0].manifest.navigationContributions.element.items.element.lazy.options[0]", "data.packages.element.options[1].manifest.actions.element.in", "data.packages.element.options[1].manifest.actions.element.in.params.element.in", - "data.packages.element.options[1].manifest.connectors.element", + "data.packages.element.options[1].manifest.connectors.element.out", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", "data.packages.element.options[1].manifest.datasets.element", @@ -806,7 +806,7 @@ }, "integration/DeclarativeConnectorEntry": { "sites": [ - "" + "out" ] }, "kernel/DisablePackageResponse": { diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index c7736aeff19..203b1d25702 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -939,7 +939,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 12 = `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`. Dead 14 = 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), plus the two declared containers `documentation` (drilled to ten, including its nested `contact` / `license`) and `responseFormat` (three) — normalized into `this.config.api` and read back by nothing, so `responseFormat.envelope: false` unwraps no response and `documentation.title` retitles 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 passed through untouched by #11646), 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. `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`) 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`, and that schema is `ConnectorSchema.superRefine(...)` — in Zod 4 a `superRefine` attaches a check to the same object def rather than wrapping it, so the walked key set is byte-identical to the base's. 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 20/1/53 split: 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 `ConnectorProviderContext` fields plus `name` and `enabled`; `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`: refused outright by ADR-0097 §3 (#7990) rather than ignored. The 53 `dead` are four declared subsystems with no engine — `syncConfig` (7), `fieldMappings` (7), `retryConfig` (8), `health` (14, both sub-blocks) — plus `triggers` (6, and the schema's own docblock says so: #3197), the connector's nested `webhooks` (one blanket verdict, recorded in the undrilled baseline), `status`, `metadata`, the two timeouts, `actions.description`/`.outputSchema`, and four `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent). **A prior in-repo claim was falsified here**: the conversion registry's note that `retryConfig` "and the timeouts beside it are untouched — they are live" is corrected on those rows — the word does not occur outside `packages/spec` at all | +| 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/44 split (live/planned/dead; counts read from the generated `state-counts.md` row, 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 44 `dead`, re-measured at this head and partitioned so every row is counted exactly once: three declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7), `health` (15, both sub-blocks) — plus `triggers` (6, and the schema's own docblock says so: #3197), the connector's nested `webhooks` (one blanket verdict, recorded in the undrilled baseline), `status`, `metadata`, `actions.description`/`.outputSchema`, and the three top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping` and `connectionTimeoutMs`. That sums to 44, the dead count the generated `state-counts.md` row carries. ⚠️ `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. ⭐ SIX 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`, `fieldMappings.transform`, `triggers.interval` and `health.circuitBreaker.monitoringWindow` — but ⛔ that six is NOT a separate addend: the last three are already inside the `fieldMappings`, `triggers` and `health` counts above, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. 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 10 `dead` are the caching block (`refreshKey.every`/`.sql` — no refresh scheduler exists anywhere), the access-control flag (`public` — three sites write `false`, nothing reads it: a knob that was never wired, not a hole that was opened), the three `description`s, and the inner `name` on each of `measures`/`dimensions`, where the record KEY is the identity. 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 fae946abaab..193ad584b19 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`, and that schema is `ConnectorSchema.superRefine(...)`. In Zod 4 a `superRefine` attaches a CHECK to the same object def rather than wrapping it, so `shapeOf()` returns `ConnectorSchema`'s shape unchanged and the key set walked here is byte-identical to the base's — 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 `ConnectorProviderContext` fields plus `name` and `enabled`; everything else in an authored entry is stored, served back by `/meta/connector`, and read by no runtime. That asymmetry is the trap this type carries, and it is recorded per key rather than asserted once. PRIOR MEASUREMENTS RE-VERIFIED, not inherited: the ADR-0087 conversion registry's `connector-field-mapping-transform-removed` entry recorded 'Execution: none — `fieldMappings` is spelled only inside packages/spec' (2026-08-06), the `syncConfig.schedule` retirement recorded '`syncConfig` has no reader outside `packages/spec`' (#16320, 2026-09-10), and `ConnectorTriggerSchema`'s own docblock says 'NOT YET ENFORCED — declared but never read by the runtime (#3197)'. All three were re-run on this checkout and all three still hold; the counts are in the rows. One prior claim FAILED re-verification and is corrected here: a comment in packages/spec/src/conversions/registry.ts asserts that `retryConfig` 'and the timeouts beside it are untouched — they are live'. They are not read anywhere; see those three rows. PREVIEW READ POINTS ENUMERATED (the #7131 mechanical rule, objectui @dda8f3815): `registerBuiltinPreviews()` registers nineteen types and `connector` is NOT one of them — 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` / `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.", "props": { "name": { "status": "live", @@ -64,7 +64,7 @@ "verifiedAt": "2026-09-17", "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `auth = await this.resolveInstanceAuth(entry.auth, resolver, name, provider)` resolves the declared `credentialRef` through the secrets/env layer and puts the RESOLVED credential on the provider context; a failure there degrades or fails the instance by name (ADR-0097 §3). packages/connectors/connector-rest/src/rest-provider.ts, packages/connectors/connector-openapi/src/openapi-provider.ts and packages/connectors/connector-mcp/src/mcp-provider.ts each read `ctx.auth` and use it to sign the upstream call. packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema refuses it on an entry with no `provider`.", "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", - "note": "The live half of the pair whose other half (`authentication`) is refused: this key is how an authored connector carries a credential at all, and it carries a REFERENCE, never the secret." + "note": "The live half of the pair whose other half (`authentication`) is refused ANY VALUE BUT `{ type: 'none' }` — the key itself is accepted and inert, so ⛔ do not compress this to '`authentication` is refused' (see that row): this key is how an authored connector carries a credential at all, and it carries a REFERENCE, never the secret." }, "actions": { "children": { @@ -194,7 +194,7 @@ "source": { "status": "dead", "verifiedAt": "2026-09-17", - "note": "Re-verified, not inherited: the ADR-0087 conversion registry's `connector-field-mapping-transform-removed` entry measured (2026-08-06) that '`fieldMappings` is spelled only inside packages/spec — the four connector packages, the automation engine, REST and objectui never read it'. That still holds on this checkout: zero occurrences of `fieldMappings` anywhere in packages/ or examples/ outside packages/spec. A connector field mapping is parsed, stored, and moves no value. ⛔ Same disposition as `syncConfig`: a decision, not a sweep — the whole L3 sync layer it belongs to is declared and unbuilt. Sub-keys below are dead for this one reason." + "note": "Re-verified, not inherited: the ADR-0087 conversion registry's `field-mapping-transform-removed` entry (that is the id in full — no `connector-` prefix; the `connector-`-prefixed neighbour is `connector-rate-limit-config-removed`, a different retirement) measured (2026-08-06) that '`fieldMappings` is spelled only inside packages/spec — the four connector packages, the automation engine, REST and objectui never read it'. That still holds on this checkout: zero occurrences of `fieldMappings` anywhere in packages/ or examples/ outside packages/spec. A connector field mapping is parsed, stored, and moves no value. ⛔ Same disposition as `syncConfig`: a decision, not a sweep — the whole L3 sync layer it belongs to is declared and unbuilt. Sub-keys below are dead for this one reason." }, "target": { "status": "dead", @@ -300,8 +300,8 @@ }, "connectionTimeoutMs": { "status": "dead", - "verifiedAt": "2026-09-20", - "note": "⚠️ STILL `dead`, and the reason is now MEASURED rather than incidental — read this row before assuming the ADR-0049 ruling on this type covered it. The ruling (实现, maintainer 「同意」 2026-09-18) said the two timeouts land together via `AbortSignal.timeout`; measured at the fetch site, they cannot. A connector's outbound call is a WHATWG `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the whole operation — nothing in that interface observes the connection phase separately. So the honest options were to bound \"time until the response arrives\" with this key, which would kill a slow-but-connected upstream the author meant to allow with a large `requestTimeoutMs` (breaking the very promise the key makes), or to leave it unenforced and say so. It is left unenforced and said so: packages/spec/src/integration/connector-fetch-policy.ts deliberately omits it from `ConnectorFetchPolicy` and carries the measurement, and a pin in packages/spec/src/integration/connector-fetch-policy.test.ts goes red if anyone aliases it onto `timeoutMs`. (Node's undici exposes `connectTimeout` through a custom dispatcher; that is Node-only and a new subsystem underneath every connector, which the same ruling forbids.) ⛔ THE PRIOR NOTE'S SECOND HALF IS NOW FALSE and is corrected here: a provider-bound instance no longer contributes only `name`/`label`/`description`/`icon`/`type`/`providerConfig`/`auth` through `ConnectorProviderContext` — it also carries `retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs`, and this key IS handed over, for a custom provider on a transport that can separate the phases. What keeps it `dead` is that no shipped consumer reads it: the census over packages/ and examples/ finds every occurrence outside packages/spec to be a WRITE (the four connector def literals, the degraded husk in packages/services/service-automation/src/plugin.ts, and the materializer line that carries it onto the context), with `requestTimeoutMs` — same census, same files — as the lit control now that it resolves to a real read. ⇒ what this key is owed is a SECOND ADR-0049 decision, on a narrower question than the one already ruled: retire it, or re-describe it as something the platform can enforce. ⛔ Do not flip this row without a consumer." + "verifiedAt": "2026-09-22", + "note": "RETIRED (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this row asked for). Tombstoned with `retiredKey` because `ConnectorSchema` is not `.strict()` and a plain delete would be a silent strip (ADR-0104); the tombstone is carried by `DeclarativeConnectorEntrySchema` too — both published carriers wrap the same private `ConnectorBaseSchema`, so they are siblings rather than parent and child — so `stack.connectors[]` and `/meta/connector` refuse it too. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Registered as `integration/Connector:connectionTimeoutMs` and `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in `RETIRED_KEYS_BY_MAJOR[18]`; authored sources and stored rows are rewritten by the D2 conversion `connector-connection-timeout-ms-removed`, and the withdrawn `ConnectorProviderContext.connectionTimeoutMs` — code, with no authored source to rewrite — by the D3 semantic entry `connector-provider-context-connection-timeout-ms-retired`. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema. ⭐ THE CARD’S CENSUS IS STALE, NOT FALSE — and the earlier draft of this note got that wrong, so it is restated as measured. The card’s Leg 2 said every occurrence outside packages/spec is a WRITE of a hardcoded 30000. Re-measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at three trees: at the card’s OWN cited SHA 0870fb5418 that is EXACTLY RIGHT — five non-spec source hits, all five `connectionTimeoutMs: 30000,` (connector-mcp mcp-connector.ts:247, connector-openapi openapi-connector.ts:220, connector-rest rest-connector.ts:113, connector-slack slack-connector.ts:94, service-automation plugin.ts:1734), the card’s table line for line. What superseded it is b929e0a662, the very PR the card flagged as pending. At `origin/main` the same instrument returns THIRTEEN non-test source occurrences over SEVEN files in FIVE packages: six READS (openapi-connector.ts:242, openapi-provider.ts:193, rest-connector.ts:134, rest-provider.ts:64, plugin.ts:307, plugin.ts:1589), four TYPE DECLARATIONS (openapi-connector.ts:135, rest-connector.ts:47, plugin.ts:291, plugin.ts:339), and THREE surviving hardcoded 30000 writes (mcp-connector.ts:247, slack-connector.ts:94, plugin.ts:1782). ⇒ read a census with the tree it was taken against, or it is not a census. What made the key `dead` was never the absence of readers but the absence of ENFORCEMENT: every one of those six reads is a pass-through whose only termini are the def `GET /connectors` echoes and the fingerprint that decides whether to re-materialize, and `connectorFetchOptions` — the one mapping onto the platform’s outbound `fetch` — was handed `{ retryConfig, requestTimeoutMs }` only. ⇒ a future census on this type counts READS and asks what each one DOES with the value; a grep count answers neither question. Use `requestTimeoutMs` (live, the row below) for the deadline the platform keeps, and bound the connect phase at a connector provider or upstream gateway on a transport that can separate the phases." }, "requestTimeoutMs": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2371b6f4407..6ab9683fb53 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8880,7 +8880,7 @@ const fieldReferenceToAlias: MetadataConversion = { * `logUnmapped`) and `ErrorMappingRuleSchema` (`sourceCode`, `sourceMessage`, * `targetCode`, `targetCategory`, `severity`, `retryable`, `userMessage`) were * authorable through `ConnectorSchema.errorMapping` — and, because - * `DeclarativeConnectorEntrySchema` `superRefine`s the same shape, through + * `DeclarativeConnectorEntrySchema` carries the same shape, through * `stack.connectors[]` and the `PUT /meta/connector/:name` door — and NOTHING * read them: measured on `origin/main`, the only reference outside the * declaring file and its unit test was a type-identity pin. No provider, @@ -8966,6 +8966,90 @@ const connectorErrorMappingRemoved: MetadataConversion = { }, }; +/** + * `connector.connectionTimeoutMs` removed (protocol 18, ADR-0049 + * enforce-or-remove; maintainer ruling 2026-09-22, letter A). + * + * A bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d key + * on `ConnectorSchema` — and, because `DeclarativeConnectorEntrySchema` wraps + * the same private base object, on `stack.connectors[]` and the + * `PUT /meta/connector/:name` door — that no site ever applied as a deadline. + * + * ⚠️ NOT a zero-mention retirement, and the distinction is the whole finding: + * five sites outside `packages/spec` READ the key. The materialization + * fingerprint and the provider-context build in + * `services/service-automation/src/plugin.ts`, `ctx.connectionTimeoutMs` in the + * `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that + * deposit it back onto the reported def. Every one of them is a pass-through: + * the value's only termini are the def `GET /connectors` echoes and the + * fingerprint that decides whether to re-materialize. `connectorFetchOptions()` + * — the one mapping from authored policy onto the platform's outbound `fetch` + * (`integration/connector-fetch-policy.ts`) — was handed + * `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not honouring + * it, and ADR-0049 forbids the parsed-unmarked-unenforced state whether the + * inert value travels or sits still. + * + * And it is not implementable where it was declared: a connector's outbound + * call is a WHATWG `fetch`, whose only cancellation surface is one + * `AbortSignal` over the whole operation, so nothing there observes the connect + * phase. `requestTimeoutMs` — the lit control for every reading above, live at + * `integration/connector-fetch-policy.ts` where it becomes `resilientFetch`'s + * per-attempt `timeoutMs` — is the bound the platform can keep. + * + * A pure lossless delete: the key never had an effect to preserve, so there is + * no value to rewrite into anything. + * + * `retiredFromLoadPath`: `ConnectorSchema` tombstones the key (`retiredKey`, + * tsc `never` + the parse-time prescription — the `errorMapping` posture one + * block over in the same schema), so a live parse refuses loudly rather than + * absorbing a key the author believes bounds a connect. This entry exists + * because a stored connector row CAN carry it: the write door + * `PUT /meta/connector/:name` parses `DeclarativeConnectorEntrySchema` and its + * output retained the authored value, and the rehydration seam + * `applyConversionsToStoredItem('connector', row)` is live for this type — both + * measured before the tombstone landed. So 17.x rows replay clean here, and + * `os migrate meta --from 17` lists the mechanical edits for author sources. + */ +const connectorConnectionTimeoutMsRemoved: MetadataConversion = { + id: 'connector-connection-timeout-ms-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.connectionTimeoutMs', + summary: + "connector key 'connectionTimeoutMs' removed (ADR-0049 — the platform never applied it as a " + + 'deadline and cannot at the site it names: a WHATWG `fetch` exposes one `AbortSignal` over ' + + 'the whole operation and never the connect phase. The value only travelled — onto the ' + + 'reported def and the materialization fingerprint. Use `requestTimeoutMs`, which ' + + "`resilientFetch` applies as each attempt's deadline, and bound the connect phase at a " + + 'provider or gateway that can separate the phases)', + apply(stack, emit) { + return mapCollection(stack, 'connectors', (c, path) => + stripKeys(c, ['connectionTimeoutMs'], emit, path)); + }, + fixture: { + before: { + connectors: [ + // Minimal by the §3 disjointness contract: the retired key and nothing + // else this major's other `connectors[]` entries also walk + // (`errorMapping`, `health.circuitBreaker.monitoringWindow`, + // `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. + { name: 'inventory_sync', label: 'Inventory Sync', type: 'saas' }, + ], + }, + after: { + connectors: [ + { name: 'ledger_api', label: 'Ledger API', type: 'api' }, + { name: 'inventory_sync', label: 'Inventory Sync', type: 'saas' }, + ], + }, + // One notice: the one connector carrying the key. + expectedNotices: 1, + }, +}; + /** * `hook.timeout` → `hook.timeoutMs` (protocol 18, #14478; maintainer ruling * 2026-09-02, recorded on the card as "ruled B"). @@ -10048,6 +10132,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly { + it('REJECTS the authored key at path `connectionTimeoutMs`, carrying the prescription', () => { + const result = ConnectorSchema.safeParse({ ...WELL_FORMED, connectionTimeoutMs: AUTHORED_MS }); + expect(result.success).toBe(false); + if (result.success) return; // narrowing; the assertion above already failed + + const issue = result.error.issues.find((i) => i.path[0] === 'connectionTimeoutMs'); + expect(issue, 'the refusal must name `connectionTimeoutMs`').toBeDefined(); + // The machine-readable half of the envelope this surface actually has. + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['connectionTimeoutMs']); + // …and the human half: the prescription IS the migration doc. + expect(issue!.message).toMatch(PRESCRIPTION); + // It must name the replacement, or an author who hits it learns only that + // something is gone. `requestTimeoutMs` is the bound the platform keeps. + expect(issue!.message).toContain('requestTimeoutMs'); + }); + + it('the inherited carrier refuses it too, so `stack.connectors[]` and the /meta door are covered', () => { + const entry = DeclarativeConnectorEntrySchema.safeParse({ ...WELL_FORMED, connectionTimeoutMs: AUTHORED_MS }); + expect(entry.success).toBe(false); + + // 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. + const door = getMetadataTypeSchema('connector'); + expect(door, 'no schema bound for `connector`').toBeDefined(); + expect(door!.safeParse({ ...WELL_FORMED, connectionTimeoutMs: AUTHORED_MS }).success).toBe(false); + + const stack = ObjectStackSchema.safeParse({ + connectors: [{ ...WELL_FORMED, connectionTimeoutMs: AUTHORED_MS }], + }); + expect(stack.success).toBe(false); + if (stack.success) return; + const issue = stack.error.issues.find((i) => i.path.join('.') === 'connectors.0.connectionTimeoutMs'); + expect(issue, 'the stack refusal must locate the key').toBeDefined(); + expect(issue!.path).toEqual(['connectors', 0, 'connectionTimeoutMs']); + + // CONTROL: the same three doors accept the same connector WITHOUT the key, + // so the refusals above are attributable to `connectionTimeoutMs` alone. + expect(DeclarativeConnectorEntrySchema.safeParse(WELL_FORMED).success).toBe(true); + expect(door!.safeParse(WELL_FORMED).success).toBe(true); + expect(ObjectStackSchema.safeParse({ connectors: [WELL_FORMED] }).success).toBe(true); + }); + + it('parses a well-formed connector and grows no `connectionTimeoutMs` property', () => { + const parsed = ConnectorSchema.parse({ ...WELL_FORMED }); + expect(parsed.name).toBe('ledger_api'); + // CONTROL: the live defaults on this schema still apply, so an empty + // reading below is the retirement and not a schema that stopped defaulting. + expect(parsed.enabled).toBe(true); + expect(parsed.requestTimeoutMs).toBe(30000); + // The non-strict strip path: absence must stay absence. If the tombstone + // were ever replaced by a plain deletion, an authored `connectionTimeoutMs` + // would be stripped in silence — this pin plus the refusals above are what + // make that regression loud. + expect(parsed).not.toHaveProperty('connectionTimeoutMs'); + }); + + it('accepts and STRIPS the retired default as inert residue, on both carriers', () => { + // #12840, maintainer ruling 2026-08-28. The key was + // `.optional().default(30000)`, so a released 17.x toolchain materialized + // `30000` into EVERY connector — authored or not — and the second door + // (`AutomationEngine.registerConnector`, which parses `ConnectorSchema` for + // a def a plugin builds IN CODE) runs no conversion that could strip it. + // So the emitted default parses as inert residue and is stripped; the + // normalized output does not carry the key, so a parse then serialize + // round-trip converges on the clean shape. + for (const [label, schema] of [ + ['base', ConnectorSchema], + ['the /meta + stack.connectors carrier', DeclarativeConnectorEntrySchema], + ] as const) { + const r = schema.safeParse({ ...WELL_FORMED, connectionTimeoutMs: 30000 }); + expect(r.success, `${label} must accept the retired default as residue`).toBe(true); + if (!r.success) continue; + expect(r.data, `${label} must STRIP it, not carry it`).not.toHaveProperty('connectionTimeoutMs'); + // CONTROL: the live sibling on the same shape is untouched by the stage. + expect(r.data.requestTimeoutMs, `${label} keeps the live sibling`).toBe(30000); + } + }); + + it('⛔ keeps the tombstone refusal for every value that is NOT the retired default', () => { + // The whole point of discriminating by value: nothing is un-retired. A + // number an author actually chose still meets the prescription. + for (const value of [15000, 1000, 300000, 29999]) { + const r = ConnectorSchema.safeParse({ ...WELL_FORMED, connectionTimeoutMs: value }); + expect(r.success, `${value} must still be refused`).toBe(false); + if (r.success) continue; + const issue = r.error.issues.find((i) => i.path[0] === 'connectionTimeoutMs'); + expect(issue, `${value} must be refused AT the key`).toBeDefined(); + expect(issue!.message, `${value} must carry the prescription`).toMatch(PRESCRIPTION); + } + // And the residue tolerance is value-identity, not type-loose: the string + // "30000" is not the emitted default. + expect(ConnectorSchema.safeParse({ ...WELL_FORMED, connectionTimeoutMs: '30000' }).success).toBe(false); + }); + + it('the residue stage leaves the walked shape intact — the pipe reads through to the base', () => { + // `acceptRetiredDefaultResidue` returns a preprocess PIPE, not a ZodObject. + // The authorable-surface and liveness walkers duck-test `.shape`, so a + // wrapper that lost it would silently drop this whole def from both + // ratchets while every parse pin above stayed green. + 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 the retired key in the walked shape`) + .toContain('connectionTimeoutMs'); + expect(Object.keys(shape!), `${label} keeps its live neighbours`).toContain('requestTimeoutMs'); + } + }); + + it('fails tsc at the authoring site: the input type of the key is `never`', () => { + const connector: Connector = { + ...WELL_FORMED, + // @ts-expect-error — `connectionTimeoutMs` is a retiredKey() tombstone: + // its input type is `never`, so a typed literal cannot carry it. + connectionTimeoutMs: AUTHORED_MS, + }; + // The parse channel agrees with the type channel on the same literal. + expect(ConnectorSchema.safeParse(connector).success).toBe(false); + }); +}); + +describe('connector.connectionTimeoutMs retirement — the D2 conversion, and why one is owed', () => { + it('a STORED connector row can carry the key — the measurement that made D2 owed', () => { + // ⭐ This is the ruling's one deliberately-unanswered question ("the dev + // measures"), re-taken here so it cannot rot into an assumption. Two legs: + // + // 1. The write door persists what it parses. `getMetadataTypeSchema + // ('connector')` is what `PUT /api/v1/meta/connector/:name` validates + // against, and before the tombstone its output RETAINED the authored + // value — so the number reached `sys_metadata`. It is refused now, which + // is exactly why rows written before this release still hold it. + // 2. The rehydration seam is live for this type: a stored `connector` row + // replayed through `applyConversionsToStoredItem` reaches the conversion + // chain at all. Measured by the strip below rather than assumed. + // + // Had EITHER leg come back empty — no schema bound for `connector`, or a + // seam that never reaches this type — the answer would have been D3-only, + // which is what the ruling's prescription alone would have produced. + const stored: Record = { + ...WELL_FORMED, + connectionTimeoutMs: AUTHORED_MS, + requestTimeoutMs: 12000, + }; + const notices: { conversionId?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('connector', stored, { + onNotice: (n) => notices.push(n as { conversionId?: string }), + }) as Record; + + expect(notices.map((n) => n.conversionId)).toContain('connector-connection-timeout-ms-removed'); + expect(rehydrated).not.toHaveProperty('connectionTimeoutMs'); + // CONTROL: the seam rewrote the retired key and nothing else — the live + // sibling on the same row survives byte-for-byte. + expect(rehydrated.requestTimeoutMs).toBe(12000); + expect(rehydrated.name).toBe('ledger_api'); + }); + + it('strips the key from `connectors[]` — one attributed notice per connector', () => { + const { stack, notices } = collectConversionNotices( + { + connectors: [ + { ...WELL_FORMED, connectionTimeoutMs: AUTHORED_MS }, + // Never authored the key: rides through untouched. + { name: 'inventory_sync', label: 'Inventory Sync', type: 'saas' }, + ], + }, + { includeRetired: true }, + ); + expect(stack).toEqual({ + connectors: [ + { name: 'ledger_api', label: 'Ledger API', type: 'api' }, + { name: 'inventory_sync', label: 'Inventory Sync', type: 'saas' }, + ], + }); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ + conversionId: 'connector-connection-timeout-ms-removed', + toMajor: 18, + path: 'connectors[0].connectionTimeoutMs', + }); + // And the stripped entry parses through the real authoring schema: the + // conversion output is exactly what the tombstone accepts. + const stripped = (stack.connectors as unknown[])[0]; + expect(DeclarativeConnectorEntrySchema.safeParse(stripped).success).toBe(true); + + // Idempotence, measured rather than asserted from `stripKeys`'s shape: a + // second replay over the converted snapshot converts nothing — zero + // notices, and the copy-on-write contract hands the input back by reference. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); +}); + +describe('connector.connectionTimeoutMs retirement — ADR-0087 registration', () => { + it('declares both carrier keys under major 18, with the D2 conversion in the step-18 chain', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('integration/Connector:connectionTimeoutMs'); + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('integration/DeclarativeConnectorEntry:connectionTimeoutMs'); + + const step = MIGRATIONS_BY_MAJOR[18]; + expect(step, 'the step-18 chain must exist').toBeDefined(); + expect(step!.conversionIds).toContain('connector-connection-timeout-ms-removed'); + }); + + it('declares the withdrawn provider-context member as a D3 semantic entry', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic + .find((s) => s.id === 'connector-provider-context-connection-timeout-ms-retired'); + expect(entry, 'the withdrawn ConnectorProviderContext member needs its own D3 entry').toBeDefined(); + // Non-empty by contract (`spec-property-retirement` §3), and it must name + // the replacement rather than only the removal. + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + expect(entry!.replacement).toContain('requestTimeoutMs'); + }); + + it('retires NO def — the key was a bare number, not a config shape', () => { + // Route-dependent reading (`spec-property-retirement` §2): on a whole-def + // removal the four ratchets MUST move, on a key-only tombstone they move by + // exactly the `[RETIRED]` row. Asserting the def table is untouched keeps a + // future reader from judging this retirement against the wrong expectation. + for (const def of RETIRED_DEFS_BY_MAJOR[18] ?? []) { + expect(def, 'no integration def leaves with this key').not.toMatch(/^integration\/Connector(Timeout|Connection)/); + } + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// What this leg guarantees. `tsc` is the primary sweeper — `retiredKey()` types +// the key `never`, so every TypeScript authoring site in the monorepo 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 five +// repo roots, each already declared for `@objectstack/spec#test` in +// `scripts/cross-package-test-inputs.mjs` and mirrored in `turbo.json`, so a +// resurrection inside the radius puts this suite into `turbo ls --affected`. +// ⛔ A tree-scoped pin whose radius is undeclared is not a completed retirement +// (`spec-property-retirement` §4) — the declaration is half the pin. +// +// The bound, stated: `docs/**`, `.claude/**`, `.github/**` and the repo-root +// files are outside the walk, as they are for the #15513 pin this copies. +// +// The matcher judges an AUTHORING SHAPE, never a mention: `connectionTimeoutMs` +// in key position, or read off an object. Prose about the retirement spells the +// name inside backticks and is not matched — which is what lets the retirement +// kit itself describe what it removed without reporting itself as an offender. +describe('tree-scoped absence: nothing inside the declared radius still authors the key', () => { + 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('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + 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']); + + /** + * An AUTHORING of the key, never a prose mention: + * - key position — `connectionTimeoutMs:` in TS/JSON/YAML, not preceded by + * a word character (a longer identifier ending in this name); + * - a member read — `.connectionTimeoutMs`, i.e. a consumer pulling the + * value back off an object. + */ + const AUTHORING = /(^|[^\w.])connectionTimeoutMs["']?\s*:|\.connectionTimeoutMs\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". Deliberately 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, ''); + + /** + * Structural exclusions — the retirement kit and its projections, each with + * its reason. ⛔ NOT an allowlist file (`spec-property-retirement` §4): every + * entry is a file whose JOB is to spell the retired key. + */ + const EXCLUDED = new Set([ + // The tombstone itself — the key is still a property of the walked shape. + 'packages/spec/src/integration/connector.zod.ts', + // The ledger row, which `retiredKey()` keeps in the walked shape. + 'packages/spec/liveness/connector.json', + // The pin that holds a stray leftover to reaching nothing. + 'packages/spec/src/integration/connector-fetch-policy.test.ts', + // The host-side negative pin: it authors the key to prove the carry is gone. + 'packages/services/service-automation/src/connector-materialization.test.ts', + // This pin names it 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 (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 (`.gitignore` line for `packages/spec/json-schema/`), + // reached only because this is a FILESYSTEM walk rather than a git walk. + // `retiredKey()` emits the tombstoned property into the generated JSON + // Schema, exactly as it already does for the two retired siblings on this + // same schema — measured: `Connector.json` carries `rateLimitConfig` and + // `errorMapping` the same way. Excluding it costs no coverage: the source + // it is generated from is `connector.zod.ts`, which this walk reads. + '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$/; + + const vanished: string[] = []; + /** + * Read a path the walk enumerated, tolerating ONLY its disappearance: a path + * that no longer exists cannot be an authoring that SURVIVES. ⛔ Every other + * read failure is re-raised — a blanket `catch` would turn an unreadable tree + * into a silent green. + */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention (anti-vacuity)', () => { + const judge = (line: string): boolean => AUTHORING.test(stripInlineCode(line)); + + expect(judge(' connectionTimeoutMs: 30000,')).toBe(true); + expect(judge('connectionTimeoutMs: 5000')).toBe(true); + expect(judge(' "connectionTimeoutMs": 30000,')).toBe(true); + expect(judge('connectionTimeoutMs: 15000 # yaml')).toBe(true); + expect(judge('const ms = ctx.connectionTimeoutMs;')).toBe(true); + expect(judge('entry.connectionTimeoutMs ?? null')).toBe(true); + // ⛔ NARROWNESS of the strip: it must not swallow a real authoring that + // merely shares a line with inline code, or the pin goes quiet. + expect(judge('// see `requestTimeoutMs` — connectionTimeoutMs: 30000,')).toBe(true); + // Prose: the retirement kit must be able to describe what it removed. + expect(judge('`connectionTimeoutMs` was removed in @objectstack/spec 17')).toBe(false); + expect(judge('the factories read `ctx.connectionTimeoutMs` only to echo it')).toBe(false); + expect(judge('| **connectionTimeoutMs** | `never` | optional | [REMOVED] `connector.connectionTimeoutMs` was removed |')).toBe(false); + expect(judge('the connectionTimeoutMs key is gone')).toBe(false); + expect(judge('"integration/Connector:connectionTimeoutMs",')).toBe(false); + // A longer identifier that merely ends in the name is not this key. + expect(judge(' defaultConnectionTimeoutMs: 30000,')).toBe(false); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + // POSITIVE CONTROL: a path that IS there is read, so the guard cannot be + // passing by refusing to read anything. + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(vanished.length).toBe(before + 1); + // ⛔ A NON-ENOENT fault is re-raised: reading a DIRECTORY raises EISDIR. + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + 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, rel); + if (text === undefined) continue; + const m = AUTHORING.exec(stripInlineCode(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 key means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/integration/connector-fetch-policy.test.ts b/packages/spec/src/integration/connector-fetch-policy.test.ts index 255dec9d215..9b65ce193fe 100644 --- a/packages/spec/src/integration/connector-fetch-policy.test.ts +++ b/packages/spec/src/integration/connector-fetch-policy.test.ts @@ -15,11 +15,17 @@ describe('connectorFetchOptions', () => { }); it('⛔ does NOT map connectionTimeoutMs — one fetch signal cannot bound the connect phase', () => { - // The key is deliberately absent from ConnectorFetchPolicy, so the only - // way it could reach the wrapper is an alias onto `timeoutMs`. This pin - // goes red the moment someone adds one, which is the whole reason it - // exists: two keys silently meaning one thing is the shape this card - // removes, not a shape it may introduce elsewhere. + // The key is absent from ConnectorFetchPolicy, so the only way it could + // reach the wrapper is an alias onto `timeoutMs`. This pin goes red the + // moment someone adds one, which is the whole reason it exists: two keys + // silently meaning one thing is the shape this mapping removes, not a + // shape it may introduce elsewhere. + // + // The key is now RETIRED from `ConnectorSchema` and from + // `ConnectorProviderContext` (ADR-0049) — precisely because it could + // never be mapped here. The pin survives the retirement on purpose: it + // is what makes a re-introduction as a silent alias fail, and a stray + // leftover in a caller-built policy object still has to reach nothing. const opts = connectorFetchOptions( { connectionTimeoutMs: 1500 } as unknown as ConnectorFetchPolicy, ); diff --git a/packages/spec/src/integration/connector-fetch-policy.ts b/packages/spec/src/integration/connector-fetch-policy.ts index fb104458ef5..7b42b4c5c01 100644 --- a/packages/spec/src/integration/connector-fetch-policy.ts +++ b/packages/spec/src/integration/connector-fetch-policy.ts @@ -54,7 +54,7 @@ export type { ResilientFetchOptions } from '../shared/resilient-fetch'; * floors its attempt count at 1, so a connector still makes its one call and * never retries — ONE owner for that floor, not a second one in this mapping. * - * ## ⚠️ `connectionTimeoutMs` is deliberately NOT mapped here + * ## ⚠️ `connectionTimeoutMs` was never mapped here, and is now RETIRED * * Measured at the fetch site rather than assumed: a connector's outbound call is * a WHATWG `fetch`, whose only cancellation surface is one `AbortSignal` @@ -64,11 +64,15 @@ export type { ResilientFetchOptions } from '../shared/resilient-fetch'; * connected upstream that the author meant to allow via a large * `requestTimeoutMs`, i.e. it would break the very promise it claims to keep. * (Node's undici exposes `connectTimeout` through a custom dispatcher, but that - * is Node-only and a new subsystem underneath every connector.) So the key - * stays unenforced and `packages/spec/liveness/connector.json` keeps it `dead` - * with that reason. ⛔ Do not "fix" this by aliasing it onto `timeoutMs`: two - * keys that silently mean one thing is the declared-not-enforced shape this - * mapping exists to remove. + * is Node-only and a new subsystem underneath every connector.) + * + * That measurement is what made this an ADR-0049 removal rather than an + * implementation: the key is gone from `ConnectorSchema` (a `retiredKey()` + * tombstone) and from `ConnectorProviderContext`, so the absent row above is no + * longer a declared-but-unmapped key — there is nothing left to map. ⛔ Do not + * "restore" it by aliasing a connect deadline onto `timeoutMs`: two keys that + * silently mean one thing is the declared-not-enforced shape this mapping + * exists to remove, and it is the shape the retirement just closed. */ /** The slice of a {@link Connector} this mapping reads. */ diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 49b5c950e2b..dd3effa79ac 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -54,12 +54,15 @@ export interface ConnectorMaterialization { * secrets/env layer, so the factory receives a usable static credential rather * than a raw reference (`undefined` when the entry declares no auth). * - * It also carries the entry's **resilience policy** — `retryConfig`, - * `connectionTimeoutMs`, `requestTimeoutMs` — so a provider that performs its - * own I/O can honour what the author declared. Before that, those keys were - * parsed and then reached nothing: a factory was never handed them and had no - * way to honour them, which is what `packages/spec/liveness/connector.json` - * recorded as `dead`. + * It also carries the entry's **resilience policy** — `retryConfig` and + * `requestTimeoutMs` — so a provider that performs its own I/O can honour what + * the author declared. Before that, those keys were parsed and then reached + * nothing: a factory was never handed them and had no way to honour them, which + * is what `packages/spec/liveness/connector.json` recorded as `dead`. + * + * ⚠️ `connectionTimeoutMs` was a third member and is **removed** with the spec + * key (ADR-0049) — see the comment at its former position below. Being handed a + * value is not the same as honouring it, and nothing ever did. */ export interface ConnectorProviderContext { readonly name: string; @@ -81,19 +84,19 @@ export interface ConnectorProviderContext { * what makes the keys live rather than merely carried. */ readonly retryConfig?: RetryConfigParsed; - /** - * The entry's declared connect deadline (ms), carried verbatim. - * - * ⚠️ **Carried, not enforced by the built-in HTTP path** — a WHATWG `fetch` - * exposes one `AbortSignal` for the whole operation and never the connection - * phase alone, so the platform has nowhere to apply a connect-only bound and - * deliberately does not pretend otherwise (see - * `connector-fetch-policy.ts`). It is handed over because a custom provider - * on a transport that CAN separate the phases (a database client, a pooled - * socket) is able to honour it; `packages/spec/liveness/connector.json` - * records the platform side as `dead` for that reason. - */ - readonly connectionTimeoutMs?: number; + // `connectionTimeoutMs` — REMOVED (ADR-0049 enforce-or-remove). #18975 added + // it here as a pure carry: "handed over so a custom provider on a transport + // that CAN separate the phases could honour it". Measured before removal, no + // provider did — the built-in `rest` and `openapi` factories read + // `ctx.connectionTimeoutMs` only to deposit it back onto the def that + // `GET /connectors` echoes, and `connectorFetchOptions()` was never handed it. + // Carrying an inert number across a published interface is the same + // parsed-unmarked-unenforced state on one more surface, so the carry is + // withdrawn with the key. There is no source for a D2 conversion to rewrite + // here — a factory is code — which is why the withdrawal is declared as the + // D3 semantic entry `connector-provider-context-connection-timeout-ms-retired` + // rather than a conversion. A factory that needs a connect bound reads it from + // its own `providerConfig`, where the provider owns the vocabulary. /** * The entry's declared per-request deadline (ms). The built-in HTTP path * applies it as `resilientFetch`'s per-attempt timeout. diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index bf9d44f754a..4ac2948ca4d 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -718,7 +718,8 @@ describe('[#4911] `./integration` no longer publishes an outbound rate-limit sha // Reachability, demonstrated rather than asserted from prose: the tombstone // is only worth anything if it fires through `stack.connectors[]`, which is // the surface an author actually writes (`DeclarativeConnectorEntrySchema` - // is `ConnectorSchema.superRefine(…)`, so it inherits the tombstone). + // wraps the same private `ConnectorBaseSchema` the base export does, so it + // carries the tombstone). // No top-level `name` here: it was never a declared stack key — the strip- // mode schema used to swallow it, and the #8687 strict close refuses it, // which would have made the positive control below fail for the wrong @@ -1196,8 +1197,12 @@ describe('ADR-0010 protection envelope (#6362)', () => { }); it('declaring the envelope did not open the schema to arbitrary `_` keys', () => { - // `ConnectorSchema` is deliberately non-strict (subtypes `.extend()` it), - // so an unknown key is stripped rather than refused. The point of this pin + // `ConnectorSchema` is deliberately non-strict, so an unknown key is + // stripped rather than refused. (This used to say "subtypes `.extend()` + // it" — the export is a residue-stage PIPE since the ADR-0049 + // `connectionTimeoutMs` retirement and `.extend()` no longer exists on it; + // a subtype extends `ConnectorBaseSchema` and re-wraps. The non-strictness + // this pin is about is the INNER object's and is unchanged.) The point // is the converse of the ones above: the spread adds SEVEN named keys, not // a passthrough — an underscore key nobody declared still does not survive. const parsed = ConnectorSchema.parse({ diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 44809363687..4433a2c6695 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -5,7 +5,7 @@ import { WebhookSchema } from '../automation/webhook.zod'; import { ConnectorAuthConfigSchema, ConnectorInstanceAuthSchema } from '../shared/connector-auth.zod'; import { FieldMappingSchema as BaseFieldMappingSchema } from '../shared/mapping.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { retiredKey } from '../shared/retired-key'; +import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; /** * Connector Protocol - LEVEL 3: Enterprise Connector @@ -58,17 +58,20 @@ import { retiredKey } from '../shared/retired-key'; * spaces out the calls you already made, it does not cap the rate, so the * sentence above about rate limiting stands unchanged. * - * ⛔ **Two exceptions, both still inert and both still `dead` in - * `packages/spec/liveness/connector.json`.** `health.circuitBreaker`: every - * sub-key is unread and no breaker ever opens — implement circuit breaking in - * the connector provider. `connectionTimeoutMs`: it is carried to a provider - * factory but the platform does not enforce it, because a WHATWG `fetch` + * ⛔ **One exception remains, still inert and still `dead` in + * `packages/spec/liveness/connector.json`:** `health.circuitBreaker` — every + * sub-key is unread and no breaker ever opens; implement circuit breaking in + * the connector provider. + * + * `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 + * it is not implementable where it was declared, because a WHATWG `fetch` * exposes one `AbortSignal` over the whole operation and never the connection - * phase alone; `requestTimeoutMs` is the bound the platform can keep, and - * ADR-0049 owes this one key a narrower decision. The full removal reasoning - * for the rate-limit shape is recorded at the removal site: the "REMOVED: - * outbound rate limiting" block in `integration/connector.zod.ts`, and - * `packages/spec/docs/SYNC_ARCHITECTURE.md`. + * phase alone. `requestTimeoutMs` is the bound the platform can keep. The full + * removal reasoning is recorded at each removal site: the "REMOVED: + * `connectionTimeoutMs`" and "REMOVED: outbound rate limiting" blocks in + * `integration/connector.zod.ts`, and `packages/spec/docs/SYNC_ARCHITECTURE.md`. * * **Field mapping does not transform values.** This header used to offer "field * mapping and transformations"; only the first half was ever true. @@ -534,6 +537,120 @@ const ERROR_MAPPING_RETIRED = + "provider's own errors (ADR-0097). " + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; +// ============================================================================ +// REMOVED: `connectionTimeoutMs` — the connect-phase deadline (ADR-0049) +// ============================================================================ +// +// A bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d key +// on this schema and — because both published carriers wrap the same private +// `ConnectorBaseSchema` — on +// `DeclarativeConnectorEntrySchema`, so it was authorable from `stack.connectors[]`, +// from `PUT /meta/connector/:name`, and served back by `/meta/connector`. Every +// signal an authoring surface can give said it worked. +// +// ⚠️ It was not merely unimplemented — it is NOT IMPLEMENTABLE AT THE SITE IT +// NAMES, which is why ADR-0049's `实现` arm was unavailable and the second +// decision came out `retire`. A connector's outbound call is a WHATWG `fetch`, +// whose only cancellation surface is ONE `AbortSignal` covering the whole +// operation; nothing in that interface observes the connection phase +// separately. The two honest readings were both losses: bound "time until the +// response arrives" with this key — which kills a slow-but-connected upstream +// the author meant to allow with a large `requestTimeoutMs`, breaking the very +// promise the key makes — or leave it unenforced and say so. (Node's undici +// exposes `connectTimeout` through a custom dispatcher; Node-only, and a new +// subsystem underneath every connector, which the #18975 ruling forbids.) +// +// ⚠️ The carry was real and is what this removal actually withdraws, so do not +// read it as a zero-mention retirement. #18975 put the key on +// `ConnectorProviderContext`, and five sites outside `packages/spec` READ it: +// the materialization fingerprint and the context build in +// `services/service-automation/src/plugin.ts`, `ctx.connectionTimeoutMs` in the +// `rest` and `openapi` provider factories, and the `?? 30000` fallbacks that +// deposited it back onto the reported def. Measured across all five, the +// value's only termini were the def `GET /connectors` echoes and the +// fingerprint that decides whether to re-materialize — never a deadline. +// `connectorFetchOptions()` (`integration/connector-fetch-policy.ts`) is the one +// mapping from authored policy onto the platform's outbound `fetch`, and it was +// handed `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not +// honouring it: ADR-0049 forbids the parsed-unmarked-unenforced state whether +// the inert value travels or sits still. +// +// `requestTimeoutMs` is the replacement and the lit control for every reading +// above — same schema, same census, same files — because it resolves to a real +// read. The live record is in the tree, not in a card number: +// `integration/connector-fetch-policy.ts` maps it onto `opts.timeoutMs`, +// `resilientFetch`'s per-attempt deadline, and `connector-fetch-policy.test.ts` +// pins that mapping. Bound the connect phase at a provider or gateway that can +// see it. +// +// `ConnectorSchema` is NOT `.strict()`, so a plain delete would be a silent +// strip (ADR-0104); the tombstone below makes the removal audible in the two +// channels an upgrading author actually hits — `tsc` and the parse. Registered +// as `integration/Connector:connectionTimeoutMs` and +// `integration/DeclarativeConnectorEntry:connectionTimeoutMs` in +// `RETIRED_KEYS_BY_MAJOR[18]`; stored rows and authored sources are rewritten by +// the D2 conversion `connector-connection-timeout-ms-removed`, and the withdrawn +// `ConnectorProviderContext` member by the D3 semantic entry +// `connector-provider-context-connection-timeout-ms-retired`. +// +// No orphaned def leaves with it: the key was a bare `z.number()`, not a +// `ConfigSchema` shape, so `RETIRED_DEFS_BY_MAJOR[18]` gains nothing here. + +/** + * The prescription an author meets when they write `connectionTimeoutMs` — in + * `tsc` (the key's input type is `never`) and at parse (this string is the + * issue message). It IS the migration doc for whoever hits it; the closing + * sentence is the house `os migrate meta` form pinned by + * `shared/retired-key-migrate-sentence.test.ts`. + */ +const CONNECTION_TIMEOUT_MS_RETIRED = + '`connector.connectionTimeoutMs` was removed in @objectstack/spec 17 (ADR-0049 ' + + 'enforce-or-remove) — the platform never honoured it and cannot honour it where it was ' + + "declared: a connector's outbound call is a WHATWG `fetch`, whose only cancellation " + + 'surface is one `AbortSignal` over the whole operation, so nothing there observes the ' + + 'connection phase separately, and the value only ever travelled (onto the reported def ' + + 'and the materialization fingerprint) without ever bounding a connect. Delete the key. ' + + 'Use `requestTimeoutMs` for the deadline the platform does keep — it is applied as ' + + "`resilientFetch`'s per-attempt timeout — and bound the connect phase at a connector " + + 'provider or upstream gateway on a transport that can separate the phases. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + +/** + * The retired default the published 17.x toolchain MATERIALIZED, captured as a + * literal because nothing else records it once the declaration is gone + * (#12840, maintainer ruling 2026-08-28; the class rule is + * `shared/retired-key.ts` — "the next retirement of a defaulted key reuses this + * helper with its own captured literal instead of re-inventing the judgement"). + * + * ⭐ WHY THIS RETIREMENT IS OWED THE STAGE, measured rather than argued by + * analogy. `connectionTimeoutMs` was declared `.optional().default(30000)`, so a + * 17.x parse emitted it into EVERY connector — authored or not. Measured across + * two builds: on the base build `ObjectStackSchema.parse({ connectors: [{ name, + * label, type }] })` returns an entry whose keys are `authentication`, + * `connectionTimeoutMs`, `enabled`, `label`, `name`, `requestTimeoutMs`, + * `status`, `type` — the author typed three of those. Feed that exact emitted + * object back to the tombstoned build and it is refused at + * `connectors.0.connectionTimeoutMs`. + * + * That residue is not hypothetical, because this schema has TWO DOORS (the fact + * `liveness/connector.json` records at the top of its `_note`): besides the + * authoring doors, `AutomationEngine.registerConnector` parses `ConnectorSchema` + * for a def a PLUGIN or an ADR-0097 provider factory builds IN CODE. A connector + * package still compiled against 17.x carries the materialized `30000` in that + * def literal — every one of the four shipped connectors did, which is what the + * card counted as its five hardcoded writes — so without this stage a 17.x + * plugin fails registration on a value its author never typed. + * + * ⛔ Nothing is un-retired: `z.input` stays `never` (authoring it is still a tsc + * error), the `[RETIRED]` authorable-surface row stays, and any OTHER value — + * `15000`, `1000` — keeps the tombstone's refusal with the prescription + * byte-for-byte. Only the emitted default is accepted, and it is STRIPPED, so a + * parse → serialize round-trip converges on the clean shape. + */ +const CONNECTOR_RETIRED_KEY_RESIDUE = { + connectionTimeoutMs: 30000, +} as const; + // ============================================================================ // Health Check & Circuit Breaker Configuration // ============================================================================ @@ -735,7 +852,7 @@ export type ConnectorTrigger = z.input; * Base Connector Schema * Core connector configuration shared across all connector types */ -export const ConnectorSchema = lazySchema(() => z.object({ +const ConnectorBaseSchema = lazySchema(() => z.object({ /** * Machine name (snake_case) */ @@ -864,10 +981,16 @@ export const ConnectorSchema = lazySchema(() => z.object({ retryConfig: RetryConfigSchema.optional().describe('Retry configuration'), /** - * Connection timeout in milliseconds + * `connectionTimeoutMs` — RETIRED (ADR-0049 enforce-or-remove). A bounded, + * defaulted, served-back key that no site ever applied as a deadline, and one + * that is not implementable where it was declared: a WHATWG `fetch` exposes a + * single `AbortSignal` over the whole operation and never the connect phase. + * `requestTimeoutMs` below is the bound the platform can keep. The section + * comment above `CONNECTION_TIMEOUT_MS_RETIRED` records the measurement, + * including the five reader sites #18975 created and what this withdraws. */ - connectionTimeoutMs: z.number().min(1000).max(300000).optional().default(30000).describe('Connection timeout in ms'), - + connectionTimeoutMs: retiredKey(CONNECTION_TIMEOUT_MS_RETIRED), + /** * Request timeout in milliseconds */ @@ -895,8 +1018,11 @@ export const ConnectorSchema = lazySchema(() => z.object({ * nobody anything. `ConnectorSchema` is NOT `.strict()`, so a plain delete * would be a silent strip (ADR-0104); the tombstone makes the removal audible * in the two channels an upgrading author actually hits — `tsc` and the - * parse — and `DeclarativeConnectorEntrySchema` (`ConnectorSchema.superRefine`) - * inherits it, so `stack.connectors[]` and the `/meta/connector` door refuse + * parse — and `DeclarativeConnectorEntrySchema` carries it too (both + * published carriers wrap the same private `ConnectorBaseSchema`; until the + * `connectionTimeoutMs` retirement the entry schema was literally + * `ConnectorSchema.superRefine(...)`), so `stack.connectors[]` and the + * `/meta/connector` door refuse * it too. Registered as `integration/Connector:errorMapping` and * `integration/DeclarativeConnectorEntry:errorMapping` in * `RETIRED_KEYS_BY_MAJOR[18]`; sources are rewritten by the D2 conversion @@ -946,6 +1072,29 @@ export const ConnectorSchema = lazySchema(() => z.object({ ...MetadataProtectionFields, })); +/** + * Core connector configuration — the authorable shape behind the ruled + * retired-default residue stage (see {@link CONNECTOR_RETIRED_KEY_RESIDUE}). + * + * The wrapper is a `z.preprocess` PIPE, not a `ZodObject`: it keeps a + * read-through `shape` so the schema walkers and shape-reading consumers see + * the inner authorable truth, but the `ZodObject` combinators do NOT survive + * it. Measured on the built entry against a plain-object control + * (`WebhookConfigSchema`, which keeps all nine): `.extend()`, `.omit()`, + * `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()` and + * `.safeExtend()` are gone. + * + * ⚠️ `.superRefine()` is the exception and the trap — it lives on zod's base + * type, so it is still CALLABLE here and silently returns a schema with no + * read-through `shape`, which is precisely what the authorable-surface and + * liveness walkers duck-test. So: build on `ConnectorBaseSchema` and re-wrap, + * the way `DeclarativeConnectorEntrySchema` below does (the + * `EffectiveObjectPermissionSchema` precedent). + */ +export const ConnectorSchema = lazySchema(() => + acceptRetiredDefaultResidue(ConnectorBaseSchema, CONNECTOR_RETIRED_KEY_RESIDUE), +); + export type Connector = z.input; /** Post-parse shape of {@link Connector} — defaults applied, transforms run (ADR-0122). */ export type ConnectorParsed = z.infer; @@ -964,8 +1113,26 @@ export function defineConnector(config: z.input): Connec * plus the cross-field rules that apply only when a connector is *authored inside * a stack*, as opposed to a def a plugin builds at runtime and hands to * `registerConnector`. `stack.zod.ts` validates the `connectors:` array against - * this; the base {@link ConnectorSchema} stays a plain object so connector - * *subtypes* (github / database / …) can still `.extend()` it. + * this. + * + * ⚠️ This used to end "the base {@link ConnectorSchema} stays a plain object so + * connector *subtypes* (github / database / …) can still `.extend()` it". That + * is NO LONGER TRUE and the sentence is corrected rather than deleted, because + * it was quoted verbatim downstream: both published exports are now + * `z.preprocess` PIPES (the ADR-0049 retired-default residue stage), and a pipe + * is not a `ZodObject`. Measured on the built entry, against a plain-object + * control (`WebhookConfigSchema`) that keeps all nine: `.extend()`, `.omit()`, + * `.pick()`, `.partial()`, `.merge()`, `.strict()`, `.keyof()` and + * `.safeExtend()` are all gone from `ConnectorSchema` and from this schema. + * `.superRefine()` is the one that survives — it lives on zod's base type — but + * ⛔ calling it on a pipe returns a schema with NO read-through `shape`, which + * is what the authorable-surface and liveness walkers duck-test, so a + * refinement still belongs on the inner object. + * + * **Subtype route:** extend `ConnectorBaseSchema` and re-wrap the result with + * `acceptRetiredDefaultResidue(…, CONNECTOR_RETIRED_KEY_RESIDUE)` — exactly + * what this schema does below, and the `EffectiveObjectPermissionSchema` + * precedent. * * One rule applies to EVERY authored entry (#7990, maintainer-ruled 2026-08-12): * - NO entry may inline secrets via `authentication`. A published connector @@ -983,7 +1150,15 @@ export function defineConnector(config: z.input): Connec * authoring both the instance and its actions reintroduces drift (§5 non-goals). */ export const DeclarativeConnectorEntrySchema = lazySchema(() => - ConnectorSchema.superRefine((entry, ctx) => { + // [#12840 precedent] The ADR-0097 refusals ride on the BASE, INSIDE the + // residue stage, for the reason `ObjectPermissionSchema` records: in zod 4 + // `.superRefine()` on a `ZodObject` returns a `ZodObject` that keeps + // `.shape`, while the same call on the residue PIPE returns a schema with no + // `.shape` — and the pipe's read-through `shape` is exactly what the + // authorable-surface / liveness walkers duck-test. Wrapping second also keeps + // this door's residue tolerance identical to the base's rather than a second + // dialect. + acceptRetiredDefaultResidue(ConnectorBaseSchema.superRefine((entry, ctx) => { const isInstance = typeof entry.provider === 'string' && entry.provider.length > 0; // #7990 — the one rule that binds EVERY authored entry, descriptor and // instance alike: `authentication` is the RUNTIME shape (its secret fields @@ -1030,7 +1205,7 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => 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), ); export type DeclarativeConnectorEntry = z.input; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__connectionTimeoutMs.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__connectionTimeoutMs.ts new file mode 100644 index 00000000000..40db7dc3b80 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__connectionTimeoutMs.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `ConnectorSchema.connectionTimeoutMs` +// (maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this +// key was owed, after the ruling that made its nine ledger siblings live left +// this one dead on a stated reason rather than by oversight). The key was +// bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d and +// served back by `/meta/connector`: every signal an authoring surface can give +// said it worked. +// +// ⚠️ This is NOT the zero-mention retirement shape, and reading it as one loses +// the finding. Measured with `git grep -n connectionTimeoutMs SHA -- . +// ':!packages/spec'` at `origin/main`: THIRTEEN non-test source occurrences over +// seven files in five packages — SIX READS (openapi-connector.ts:242, +// openapi-provider.ts:193, rest-connector.ts:134, rest-provider.ts:64, +// plugin.ts:307, plugin.ts:1589), FOUR TYPE DECLARATIONS +// (openapi-connector.ts:135, rest-connector.ts:47, plugin.ts:291, +// plugin.ts:339), and THREE surviving hardcoded `30000` writes +// (mcp-connector.ts:247, slack-connector.ts:94, plugin.ts:1782). +// +// ⭐ The card's own Leg-2 table — "five non-spec mentions, all writes of a +// hardcoded 30000" — was CORRECT at the SHA it cited and dated (0870fb5418: +// exactly those five, line for line). It is STALE, not false; b929e0a662, the +// PR the card itself flagged as pending, is what moved it. ⛔ Do not re-cite +// either reading without its tree: a census is a count plus the commit it was +// taken against. +// +// Measured across all six reads, every one is a pass-through: the value's only +// termini are the def `GET /connectors` echoes and the fingerprint that decides +// whether to re-materialize. Never a deadline. +// `connectorFetchOptions()` (`integration/connector-fetch-policy.ts`) is the one +// mapping from authored policy onto the platform's outbound `fetch`, and it was +// handed `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not +// honouring it — ADR-0049 forbids the parsed-unmarked-unenforced state whether +// the inert value travels or sits still. +// +// The `实现` arm was unavailable, which is why the second decision came out +// `retire` rather than `enforce`: a connector's outbound call is a WHATWG +// `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the +// whole operation, so nothing there observes the connect phase. Bounding +// "time until the response arrives" with this key would kill a slow-but- +// connected upstream the author meant to allow with a large `requestTimeoutMs` +// — breaking the very promise the key makes. (undici's `connectTimeout` needs a +// custom dispatcher: Node-only, and a new subsystem underneath every connector.) +// `requestTimeoutMs` — the lit control for every census above — is the bound +// the platform can keep. Its live record is cited as the code rather than as a +// card number on purpose: the PR that made it live has been DELETED from the +// board (probed: minted, absent, and the web endpoint 404s), so the only +// durable anchor is `integration/connector-fetch-policy.ts`, where +// `connectorFetchOptions()` maps the key onto `resilientFetch`'s per-attempt +// `timeoutMs`, pinned by `connector-fetch-policy.test.ts`. +// +// Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, +// so a bare deletion would be a silent strip (ADR-0104). No def leaves with it — +// the key was a bare `z.number()`, not a `ConfigSchema` shape, so +// `RETIRED_DEFS_BY_MAJOR[18]` gains nothing. Authored sources and stored rows are +// rewritten by the D2 conversion `connector-connection-timeout-ms-removed`; the +// withdrawn `ConnectorProviderContext` member, which is code with no authored +// source, leaves via the D3 semantic entry +// `connector-provider-context-connection-timeout-ms-retired`. +// +// ⭐ RETIRED-DEFAULT RESIDUE: the `acceptRetiredDefaultResidue` stage IS owed +// here, and is adopted — `{ connectionTimeoutMs: 30000 }` on both carriers +// (#12840, maintainer ruling 2026-08-28; the class rule lives in +// `shared/retired-key.ts`). The discriminator across the three siblings is +// whether a released toolchain MATERIALIZED the default into something that is +// later re-parsed — `security/ObjectPermission:allowPurge` adopted the stage +// for exactly that reason, `kernel/PluginQualityMetrics:securityScan` did not +// because the key carried no default of its own, and +// `api/ListInstalledPackagesRequest:limit` did not because nothing ever parses +// through that schema so its `.default(50)` was never materialized anywhere. +// +// This key is in the FIRST bucket, measured across two builds rather than +// argued: on the base build `ObjectStackSchema.parse({ connectors: [{ name, +// label, type }] })` returns an entry carrying `connectionTimeoutMs: 30000` +// (emitted keys: authentication, connectionTimeoutMs, enabled, label, name, +// requestTimeoutMs, status, type — the author typed three), and feeding that +// exact object back to the tombstoned build is refused at +// `connectors.0.connectionTimeoutMs`. +// +// ⛔ The presence of a D2 conversion does NOT discharge this obligation, and +// reading it that way is the trap: `security/ObjectPermission:allowPurge` has +// BOTH a D2 (`permission-allow-restore-purge-removed`, in the same step-18 +// chain) AND the residue stage. The reason is the SECOND door — the fact +// `liveness/connector.json` opens its `_note` with: besides the authoring +// doors, `AutomationEngine.registerConnector` parses `ConnectorSchema` for a def +// a PLUGIN or an ADR-0097 provider factory builds IN CODE. No conversion runs +// there. A connector package still compiled against 17.x carries the +// materialized `30000` in that def literal — all four shipped connectors did, +// which is what the card counted as its hardcoded writes — so without this +// stage a 17.x plugin fails registration on a value its author never typed. +// +// Registered under 18, not 17: v17.0.0 was cut before this landed, so the +// removal ships on the 17.x line (launch-window convention: accept-set +// narrowings ride minor releases) and the prescription lives at the major +// boundary where `migrate meta` users look — the disposition +// `18.integration__Connector__errorMapping.ts` records for the same schema. +export const entry = 'integration/Connector:connectionTimeoutMs'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__connectionTimeoutMs.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__connectionTimeoutMs.ts new file mode 100644 index 00000000000..b6e92aeefb4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__connectionTimeoutMs.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The same tombstone seen through the second carrier. +// `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are now SIBLINGS, not +// parent and child: each wraps the shared private `ConnectorBaseSchema` in the +// retired-default residue stage, the entry schema adding the ADR-0097 +// cross-field rules on the base before wrapping. (Until this retirement the +// entry schema was literally `ConnectorSchema.superRefine(...)`; that spelling +// is gone with the pipe.) So the `connectionTimeoutMs` tombstone is carried by +// the shape that +// `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door +// (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface +// walk publishes the `[RETIRED]` row under this def key as well. One tombstone, +// two registered keys: gate (b) of `scripts/build-schemas.ts` reads EXACT +// `${defKey}:${name}` membership per def, never by radiating from a neighbour. +// +// This carrier is also what made the D2 conversion owed rather than optional: +// the door persists what it parses, so a stored `sys_metadata` connector row can +// carry the key — measured, not assumed, before the tombstone landed. +// See `18.integration__Connector__connectionTimeoutMs.ts` for the retirement record. +export const entry = 'integration/DeclarativeConnectorEntry:connectionTimeoutMs'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__errorMapping.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__errorMapping.ts index 9343431e214..cf7fd18b419 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__errorMapping.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__errorMapping.ts @@ -1,8 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // #14676 — the same tombstone seen through the second carrier. -// `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the -// `errorMapping` tombstone on the base is inherited by the shape that +// `DeclarativeConnectorEntrySchema` and `ConnectorSchema` both wrap the shared +// private `ConnectorBaseSchema` in the retired-default residue stage, the entry +// schema adding the ADR-0097 cross-field rules on the base before wrapping. +// (When this entry was written the two were parent and child — +// `ConnectorSchema.superRefine(...)` — and the `connectionTimeoutMs` retirement +// replaced that with the sibling shape.) Either way the `errorMapping` +// tombstone on the base is carried by the shape that // `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-provider-context-connection-timeout-ms-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-provider-context-connection-timeout-ms-retired.ts new file mode 100644 index 00000000000..31671d940ce --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-provider-context-connection-timeout-ms-retired.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'connector-provider-context-connection-timeout-ms-retired', + surface: 'ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed ' + + 'to every ConnectorProviderFactory (integration/connector-provider.ts)', + replacement: 'requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, ' + + "the provider's own providerConfig, where the provider owns the vocabulary", + reason: + 'ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire ' + + 'connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are ' + + 'rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry ' + + 'carries the half a conversion cannot reach. The key was placed on this context by the ' + + 'round that made the connector resilience policy live, explicitly as a CARRY — handed ' + + 'over so that a custom provider on a transport able to separate the phases could honour ' + + 'it. Measured before removal, none did, and the carry itself was the last thing keeping ' + + 'the key alive in argument: the built-in rest and openapi factories read ' + + 'ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors ' + + 'echoes, and connectorFetchOptions — the one mapping from authored policy onto the ' + + "platform's outbound fetch — was never handed it. Being handed a value is not honouring " + + 'it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and ' + + 'it leaves with the key rather than outliving it as an orphan a factory could still ' + + 'read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is ' + + 'no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so ' + + 'the chain has no seam to rewrite — the removal reaches a factory author as a tsc error ' + + 'and as this entry, never as a mechanical edit. The declaration cannot be made honest by ' + + 'implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation ' + + 'and never the connect phase, so bounding time-to-response with this key would kill a ' + + 'slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ' + + 'ADR-0087, ADR-0097.', + acceptanceCriteria: + 'No ConnectorProviderFactory reads ctx.connectionTimeoutMs; the member does not exist on ' + + 'ConnectorProviderContext and reading it fails to compile. A factory that genuinely needs ' + + 'a connect-phase bound declares it in its own providerConfig and applies it itself, on a ' + + 'transport that can observe the connect phase — it does not receive one from the host. ' + + 'Behaviour is unchanged for every shipped provider, because none applied the value: a ' + + 'connector that authored connectionTimeoutMs made exactly the same calls with exactly ' + + 'the same deadlines before and after. What does change is observable and intended: the ' + + 'def served by GET /connectors no longer echoes a connect deadline nobody keeps, and ' + + 'requestTimeoutMs — which resilientFetch applies as each attempt deadline — is the only ' + + 'timeout on the surface. The sibling members retryConfig and requestTimeoutMs ' + + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + + 'both resolve to real reads at the fetch site.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9d067af88f3..acb46b71592 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5211,7 +5211,35 @@ const step18: MigrationStep = { + 'dashboard widgets only — `ReportChartSchema` and the inline-data react `` ' + 'tier keep their own axes — and the paired semantic entry carries what the stripped ' + 'keys were saying, because an authored axis field may name a column the widget never ' - + 'selected and no walker can move that intent into the dataset.', + + 'selected and no walker can move that intent into the dataset. ' + + 'It also retires `connector.connectionTimeoutMs` (ADR-0049 enforce-or-remove; ' + + 'maintainer ruling 2026-09-22, letter A — the narrower SECOND decision the key was ' + + 'owed after the ruling that made its nine ledger siblings live deliberately left this ' + + 'one dead). Bounded, defaulted, `.describe()`d and served back by `/meta/connector`, ' + + 'so an author had every signal it worked — and no site ever applied it as a deadline. ' + + 'This retirement is NOT the zero-mention shape: five sites outside `packages/spec` ' + + 'read the key (the materialization fingerprint and the provider-context build in the ' + + 'automation service, `ctx.connectionTimeoutMs` in the `rest` and `openapi` provider ' + + 'factories, and the `?? 30000` fallbacks that put it back on the reported def), but ' + + 'every one is a pass-through whose only termini are the def `GET /connectors` echoes ' + + 'and the fingerprint that decides whether to re-materialize. The one mapping from ' + + 'authored policy onto the platform\'s outbound `fetch` was handed `retryConfig` and ' + + '`requestTimeoutMs` only, so the key was carried and never honoured — the same ' + + 'parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route. Nor was ' + + 'the `实现` arm available: a WHATWG `fetch` exposes one `AbortSignal` over the whole ' + + 'operation and never the connect phase, so bounding time-to-response with it would ' + + 'kill a slow-but-connected upstream the author meant to allow with a large ' + + '`requestTimeoutMs`. `requestTimeoutMs` is the replacement and the bound the platform ' + + 'can keep. The carrier key is a retiredKey tombstone on the non-strict ' + + '`ConnectorSchema` (a bare deletion would be a silent strip), registered under both ' + + 'def keys because `DeclarativeConnectorEntrySchema` carries it too, both carriers ' + + 'wrapping the same private `ConnectorBaseSchema`; the D2 conversion ' + + 'strips it from `connectors[]` as a pure lossless delete — it never had an effect to ' + + 'lose — because a stored connector row CAN carry it (the `PUT /meta/connector/:name` ' + + 'door persists the authored value and the stored-row rehydration seam is live for ' + + 'this type, both measured); and the withdrawn `ConnectorProviderContext` member, ' + + 'which is code and has no authored source to rewrite, leaves via the paired semantic ' + + 'entry instead.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5232,6 +5260,7 @@ const step18: MigrationStep = { 'form-view-option-default-removed', 'field-reference-to-alias', 'connector-error-mapping-removed', + 'connector-connection-timeout-ms-removed', 'hook-timeout-to-timeout-ms', 'job-timeout-to-timeout-ms', 'api-endpoint-cache-ttl-to-cache-ttl-seconds', @@ -6531,6 +6560,48 @@ const step18: MigrationStep = { + 'fail tsc on upgrade; the fix is choosing a shipped driver, never ' + 'widening a local mirror of the enum.', }, + { + id: 'connector-provider-context-connection-timeout-ms-retired', + surface: 'ConnectorProviderContext.connectionTimeoutMs, the declared connect deadline handed ' + + 'to every ConnectorProviderFactory (integration/connector-provider.ts)', + replacement: 'requestTimeoutMs for the deadline the platform keeps; for a connect-only bound, ' + + "the provider's own providerConfig, where the provider owns the vocabulary", + reason: + 'ADR-0049 enforce-or-remove, maintainer ruling 2026-09-22 letter A: retire ' + + 'connector.connectionTimeoutMs. The spec key is tombstoned and its authored sources are ' + + 'rewritten by the D2 conversion connector-connection-timeout-ms-removed; this entry ' + + 'carries the half a conversion cannot reach. The key was placed on this context by the ' + + 'round that made the connector resilience policy live, explicitly as a CARRY — handed ' + + 'over so that a custom provider on a transport able to separate the phases could honour ' + + 'it. Measured before removal, none did, and the carry itself was the last thing keeping ' + + 'the key alive in argument: the built-in rest and openapi factories read ' + + 'ctx.connectionTimeoutMs only to deposit it back onto the def that GET /connectors ' + + 'echoes, and connectorFetchOptions — the one mapping from authored policy onto the ' + + "platform's outbound fetch — was never handed it. Being handed a value is not honouring " + + 'it, so the carry is the same parsed-unmarked-unenforced state on one more surface, and ' + + 'it leaves with the key rather than outliving it as an orphan a factory could still ' + + 'read. Why a semantic entry and not a D2 conversion: a provider factory is CODE. There is ' + + 'no authored source and no sys_metadata row holding a read of ctx.connectionTimeoutMs, so ' + + 'the chain has no seam to rewrite — the removal reaches a factory author as a tsc error ' + + 'and as this entry, never as a mechanical edit. The declaration cannot be made honest by ' + + 'implementing it either: a WHATWG fetch exposes one AbortSignal over the whole operation ' + + 'and never the connect phase, so bounding time-to-response with this key would kill a ' + + 'slow-but-connected upstream the author meant to allow with a large requestTimeoutMs. ' + + 'ADR-0087, ADR-0097.', + acceptanceCriteria: + 'No ConnectorProviderFactory reads ctx.connectionTimeoutMs; the member does not exist on ' + + 'ConnectorProviderContext and reading it fails to compile. A factory that genuinely needs ' + + 'a connect-phase bound declares it in its own providerConfig and applies it itself, on a ' + + 'transport that can observe the connect phase — it does not receive one from the host. ' + + 'Behaviour is unchanged for every shipped provider, because none applied the value: a ' + + 'connector that authored connectionTimeoutMs made exactly the same calls with exactly ' + + 'the same deadlines before and after. What does change is observable and intended: the ' + + 'def served by GET /connectors no longer echoes a connect deadline nobody keeps, and ' + + 'requestTimeoutMs — which resilientFetch applies as each attempt deadline — is the only ' + + 'timeout on the surface. The sibling members retryConfig and requestTimeoutMs ' + + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + + 'both resolve to real reads at the fetch site.', + }, { id: 'cube-join-sql-and-relationship-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code @@ -14135,6 +14206,102 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `connectors:` is a stack collection and a published connector row lands whole // in `sys_metadata`, so the chain has a seam that sees it. 'integration/CircuitBreakerConfig:monitoringWindow', + // ADR-0049 enforce-or-remove on `ConnectorSchema.connectionTimeoutMs` + // (maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this + // key was owed, after the ruling that made its nine ledger siblings live left + // this one dead on a stated reason rather than by oversight). The key was + // bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d and + // served back by `/meta/connector`: every signal an authoring surface can give + // said it worked. + // + // ⚠️ This is NOT the zero-mention retirement shape, and reading it as one loses + // the finding. Measured with `git grep -n connectionTimeoutMs SHA -- . + // ':!packages/spec'` at `origin/main`: THIRTEEN non-test source occurrences over + // seven files in five packages — SIX READS (openapi-connector.ts:242, + // openapi-provider.ts:193, rest-connector.ts:134, rest-provider.ts:64, + // plugin.ts:307, plugin.ts:1589), FOUR TYPE DECLARATIONS + // (openapi-connector.ts:135, rest-connector.ts:47, plugin.ts:291, + // plugin.ts:339), and THREE surviving hardcoded `30000` writes + // (mcp-connector.ts:247, slack-connector.ts:94, plugin.ts:1782). + // + // ⭐ The card's own Leg-2 table — "five non-spec mentions, all writes of a + // hardcoded 30000" — was CORRECT at the SHA it cited and dated (0870fb5418: + // exactly those five, line for line). It is STALE, not false; b929e0a662, the + // PR the card itself flagged as pending, is what moved it. ⛔ Do not re-cite + // either reading without its tree: a census is a count plus the commit it was + // taken against. + // + // Measured across all six reads, every one is a pass-through: the value's only + // termini are the def `GET /connectors` echoes and the fingerprint that decides + // whether to re-materialize. Never a deadline. + // `connectorFetchOptions()` (`integration/connector-fetch-policy.ts`) is the one + // mapping from authored policy onto the platform's outbound `fetch`, and it was + // handed `{ retryConfig, requestTimeoutMs }` only. Carrying a number is not + // honouring it — ADR-0049 forbids the parsed-unmarked-unenforced state whether + // the inert value travels or sits still. + // + // The `实现` arm was unavailable, which is why the second decision came out + // `retire` rather than `enforce`: a connector's outbound call is a WHATWG + // `fetch`, whose only cancellation surface is ONE `AbortSignal` covering the + // whole operation, so nothing there observes the connect phase. Bounding + // "time until the response arrives" with this key would kill a slow-but- + // connected upstream the author meant to allow with a large `requestTimeoutMs` + // — breaking the very promise the key makes. (undici's `connectTimeout` needs a + // custom dispatcher: Node-only, and a new subsystem underneath every connector.) + // `requestTimeoutMs` — the lit control for every census above — is the bound + // the platform can keep. Its live record is cited as the code rather than as a + // card number on purpose: the PR that made it live has been DELETED from the + // board (probed: minted, absent, and the web endpoint 404s), so the only + // durable anchor is `integration/connector-fetch-policy.ts`, where + // `connectorFetchOptions()` maps the key onto `resilientFetch`'s per-attempt + // `timeoutMs`, pinned by `connector-fetch-policy.test.ts`. + // + // Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, + // so a bare deletion would be a silent strip (ADR-0104). No def leaves with it — + // the key was a bare `z.number()`, not a `ConfigSchema` shape, so + // `RETIRED_DEFS_BY_MAJOR[18]` gains nothing. Authored sources and stored rows are + // rewritten by the D2 conversion `connector-connection-timeout-ms-removed`; the + // withdrawn `ConnectorProviderContext` member, which is code with no authored + // source, leaves via the D3 semantic entry + // `connector-provider-context-connection-timeout-ms-retired`. + // + // ⭐ RETIRED-DEFAULT RESIDUE: the `acceptRetiredDefaultResidue` stage IS owed + // here, and is adopted — `{ connectionTimeoutMs: 30000 }` on both carriers + // (#12840, maintainer ruling 2026-08-28; the class rule lives in + // `shared/retired-key.ts`). The discriminator across the three siblings is + // whether a released toolchain MATERIALIZED the default into something that is + // later re-parsed — `security/ObjectPermission:allowPurge` adopted the stage + // for exactly that reason, `kernel/PluginQualityMetrics:securityScan` did not + // because the key carried no default of its own, and + // `api/ListInstalledPackagesRequest:limit` did not because nothing ever parses + // through that schema so its `.default(50)` was never materialized anywhere. + // + // This key is in the FIRST bucket, measured across two builds rather than + // argued: on the base build `ObjectStackSchema.parse({ connectors: [{ name, + // label, type }] })` returns an entry carrying `connectionTimeoutMs: 30000` + // (emitted keys: authentication, connectionTimeoutMs, enabled, label, name, + // requestTimeoutMs, status, type — the author typed three), and feeding that + // exact object back to the tombstoned build is refused at + // `connectors.0.connectionTimeoutMs`. + // + // ⛔ The presence of a D2 conversion does NOT discharge this obligation, and + // reading it that way is the trap: `security/ObjectPermission:allowPurge` has + // BOTH a D2 (`permission-allow-restore-purge-removed`, in the same step-18 + // chain) AND the residue stage. The reason is the SECOND door — the fact + // `liveness/connector.json` opens its `_note` with: besides the authoring + // doors, `AutomationEngine.registerConnector` parses `ConnectorSchema` for a def + // a PLUGIN or an ADR-0097 provider factory builds IN CODE. No conversion runs + // there. A connector package still compiled against 17.x carries the + // materialized `30000` in that def literal — all four shipped connectors did, + // which is what the card counted as its hardcoded writes — so without this + // stage a 17.x plugin fails registration on a value its author never typed. + // + // Registered under 18, not 17: v17.0.0 was cut before this landed, so the + // removal ships on the 17.x line (launch-window convention: accept-set + // narrowings ride minor releases) and the prescription lives at the major + // boundary where `migrate meta` users look — the disposition + // `18.integration__Connector__errorMapping.ts` records for the same schema. + 'integration/Connector:connectionTimeoutMs', // #14676 — ADR-0049 enforce-or-remove on `ConnectorSchema.errorMapping` (triage // ruling 2026-09-02: removal via the `spec-property-retirement` playbook; the // split condition — a downstream consumer in objectui or a customer stack — @@ -14175,9 +14342,33 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // by it). The rename does not change that; it makes the declaration honest // about its unit for whoever implements the loop. 'integration/ConnectorTrigger:interval', + // The same tombstone seen through the second carrier. + // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are now SIBLINGS, not + // parent and child: each wraps the shared private `ConnectorBaseSchema` in the + // retired-default residue stage, the entry schema adding the ADR-0097 + // cross-field rules on the base before wrapping. (Until this retirement the + // entry schema was literally `ConnectorSchema.superRefine(...)`; that spelling + // is gone with the pipe.) So the `connectionTimeoutMs` tombstone is carried by + // the shape that + // `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door + // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface + // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, + // two registered keys: gate (b) of `scripts/build-schemas.ts` reads EXACT + // `${defKey}:${name}` membership per def, never by radiating from a neighbour. + // + // This carrier is also what made the D2 conversion owed rather than optional: + // the door persists what it parses, so a stored `sys_metadata` connector row can + // carry the key — measured, not assumed, before the tombstone landed. + // See `18.integration__Connector__connectionTimeoutMs.ts` for the retirement record. + 'integration/DeclarativeConnectorEntry:connectionTimeoutMs', // #14676 — the same tombstone seen through the second carrier. - // `DeclarativeConnectorEntrySchema` is `ConnectorSchema.superRefine(...)`, so the - // `errorMapping` tombstone on the base is inherited by the shape that + // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` both wrap the shared + // private `ConnectorBaseSchema` in the retired-default residue stage, the entry + // schema adding the ADR-0097 cross-field rules on the base before wrapping. + // (When this entry was written the two were parent and child — + // `ConnectorSchema.superRefine(...)` — and the `connectionTimeoutMs` retirement + // replaced that with the sibling shape.) Either way the `errorMapping` + // tombstone on the base is carried by the shape that // `stack.connectors[]` (`stack.zod.ts`) and the `PUT /meta/connector/:name` door // (`kernel/metadata-type-schemas.ts`) actually parse, and the authorable-surface // walk publishes the `[RETIRED]` row under this def key as well. One tombstone, diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 98e1689f9e6..59cf0def517 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -28,6 +28,7 @@ "src/api/error-catalog-docs.test.ts", "src/data/api-methods-batch-conformance.test.ts", "src/identity/position-delegatable-enforcer.pin.test.ts", + "src/integration/connector-connection-timeout-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", "src/system/compliance-families-retirement.test.ts", "src/system/constants/platform-object-names.test.ts",