From 4286d53e12d41c80b9b046c7bb15dfdcd2369616 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:14:44 +0000 Subject: [PATCH 01/13] =?UTF-8?q?wip(spec):=20retire=20the=20connector=20r?= =?UTF-8?q?esilience=20family=20=E2=80=94=20health=20/=20status=20/=20nest?= =?UTF-8?q?ed=20webhooks=20tombstones,=20defs,=20D2,=20D3,=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../connector-mcp/src/mcp-connector.ts | 3 +- .../src/openapi-connector.ts | 3 +- .../connector-rest/src/rest-connector.ts | 3 +- .../connector-slack/src/slack-connector.ts | 3 +- ...declared-webhooks.connector-nested.test.ts | 19 +- .../src/bootstrap-declared-webhooks.ts | 23 +- .../services/service-automation/src/plugin.ts | 11 +- packages/spec/docs/SYNC_ARCHITECTURE.md | 69 ++- packages/spec/liveness/README.md | 2 +- packages/spec/liveness/connector.json | 96 +--- packages/spec/liveness/state-counts.md | 4 +- .../undrilled-containers.baseline.json | 1 - packages/spec/src/api/rest-server.test.ts | 7 +- packages/spec/src/automation/webhook.zod.ts | 28 +- packages/spec/src/conversions/registry.ts | 167 ++++-- .../connector-author-shape.test.ts | 72 ++- ...nnector-resilience-keys-retirement.test.ts | 540 ++++++++++++++++++ .../spec/src/integration/connector.test.ts | 276 ++------- .../spec/src/integration/connector.zod.ts | 327 ++++++----- .../18.integration__CircuitBreakerConfig.ts | 14 + .../18.integration__ConnectorHealth.ts | 9 + .../18.integration__ConnectorStatus.ts | 11 + .../18.integration__HealthCheckConfig.ts | 12 + .../18.integration__WebhookConfig.ts | 10 + .../18.integration__WebhookEvent.ts | 9 + ....integration__WebhookSignatureAlgorithm.ts | 9 + .../18.integration__Connector__health.ts | 35 ++ .../18.integration__Connector__status.ts | 32 ++ .../18.integration__Connector__webhooks.ts | 24 + ...tion__DeclarativeConnectorEntry__health.ts | 13 + ...tion__DeclarativeConnectorEntry__status.ts | 9 + ...on__DeclarativeConnectorEntry__webhooks.ts | 7 + .../18.connector-resilience-keys-retired.ts | 54 ++ packages/spec/src/migrations/registry.ts | 241 +++++++- .../src/type-alias-convention.pin.test.ts | 28 +- packages/spec/vitest.repo-tests.json | 1 + 36 files changed, 1534 insertions(+), 638 deletions(-) create mode 100644 packages/spec/src/integration/connector-resilience-keys-retirement.test.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__CircuitBreakerConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorHealth.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorStatus.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__HealthCheckConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookConfig.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookEvent.ts create mode 100644 packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookSignatureAlgorithm.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__health.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__status.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__health.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__status.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__webhooks.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts diff --git a/packages/connectors/connector-mcp/src/mcp-connector.ts b/packages/connectors/connector-mcp/src/mcp-connector.ts index e0dd0dc5135..345e5764a56 100644 --- a/packages/connectors/connector-mcp/src/mcp-connector.ts +++ b/packages/connectors/connector-mcp/src/mcp-connector.ts @@ -242,7 +242,8 @@ export async function createMcpConnector(opts: McpConnectorOptions): Promise **There is no outbound rate limiting.** This list used to carry a ticked @@ -173,10 +173,21 @@ Complete, production-grade integration with external systems. Includes authentic > advice above is unchanged: throttle at the connector provider or upstream > gateway. > -> ⛔ **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. +> **There is no health probe, no circuit breaker, no authored status and no +> connector-owned webhook.** This list used to tick "**Monitoring**: Health +> checks, metrics, logging", "**Webhooks**: Bidirectional event notifications" +> and a circuit breaker beside the retry policy. `connector.health` (the +> `healthCheck` probe and the `circuitBreaker`), `connector.status` and the +> connector-nested `webhooks` were removed in `@objectstack/spec` 17 (ADR-0049 +> enforce-or-remove) — sixteen keys that nothing read: no loop ever polled a +> connector endpoint or tripped a breaker, nothing read an authored status, and a +> webhook nested in a connector was never registered, so it was never +> delivered. Put probes and circuit breaking in the connector provider or an +> upstream gateway. Whether a registered connector can be dispatched is the +> computed `state` (`ready` / `degraded`) that `GET /api/v1/automation/connectors` +> reports, and a webhook that is actually delivered is declared in the stack's +> top-level `webhooks:` collection (`src/automation/webhook.zod.ts`). Already +> authored one of them? `os migrate meta --from 17` lists the mechanical edits. > > `connectionTimeoutMs` was the second and is **removed** (ADR-0049, the > narrower second decision it was owed). It was carried to a provider factory @@ -219,11 +230,10 @@ 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`, `requestTimeoutMs`, all of `syncConfig`'s +> `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 -> you write a connector. (`syncConfig.schedule`, the cron slot the schema used +> `batchSize` / `deleteMode`, and a mapping's `required` / `syncMode` — is +> optional when you write a connector. (`syncConfig.schedule`, the cron slot the schema used > to wrap into an envelope, was retired at #16320 under ADR-0049: nothing ever > evaluated it.) Annotate the **result** of > `ConnectorSchema.parse(…)` with **`ConnectorParsed`**, which is `z.infer`: @@ -306,23 +316,9 @@ const sapConnector: Connector = { } ], - // Webhooks for Real-time Events - webhooks: [ - { - name: 'order_created_webhook', - url: 'https://api.objectstack.com/webhooks/sap/orders', - events: ['record.created', 'record.updated'], - secret: process.env.WEBHOOK_SECRET!, - signatureAlgorithm: 'hmac_sha256', - // (`retryPolicy` sat here until #3494 retired it — webhook delivery - // retries are owned by the messaging outbox on a fixed schedule, and the - // authored policy was never read. There is no replacement, and it is a - // different thing from `retryConfig` below, which governs the calls this - // connector MAKES.) - timeoutMs: 30000, - isActive: true - } - ], + // (`webhooks` sat here until ADR-0049 retired it — a webhook nested in a + // connector was never registered, so it was never delivered. Declare + // webhooks in the stack's top-level `webhooks:` collection, which is.) // (`rateLimitConfig` sat here until #4911 retired it — no outbound // rate-limiting engine ever existed. Throttle at the provider/gateway.) @@ -349,7 +345,8 @@ const sapConnector: Connector = { // 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', + // (`status: 'active'` sat here until ADR-0049 retired the key — nothing read + // it. `enabled` is what takes a declarative instance in or out of service.) enabled: true }; ``` @@ -376,8 +373,10 @@ const sapConnector: Connector = { retry is not a throttle: it spaces out calls you already made rather than capping the rate. The throttling stays the provider's to implement - **Error Handling**: Implement comprehensive retry logic with exponential backoff -- **Monitoring**: Set up health checks and alerting for connector failures -- **Testing**: Test authentication, sync, and webhook flows thoroughly +- **Monitoring**: Set up health checks and alerting for connector failures at the + connector provider or upstream gateway — the connector shape declares no probe + and no breaker +- **Testing**: Test authentication and sync flows thoroughly - **Documentation**: Document field mappings and business logic --- @@ -395,9 +394,9 @@ mostly answers "which surface", and — for the two questions that used to route | Do you need to convert a value per field on import? | **Yes** → the import mapping's `fieldMapping[].transform` (`data/mapping.zod.ts`), applied row by row by the REST import path. **Not** L3: a connector's `fieldMappings` declares `dataType` and `syncMode` and performs no value transformation (#5552) | | Do you need joins, aggregations or custom-SQL stages? | **No surface provides this.** It was L2's headline claim and L2 had no executor (#6414). Do it in the destination system, or in a `flow` / job you write. Do not author a shape hoping it runs | | Do you need multi-source aggregation? | **Same answer**, and for the same reason — see [Retired: L2 ETL Pipeline](#retired-l2-etl-pipeline-v17) | -| Do you need real-time webhooks? | **Yes** → L3 (Connector) | +| Do you need real-time webhooks? | **Outbound:** the stack's top-level `webhooks:` collection (`src/automation/webhook.zod.ts`) — **not** L3: a connector's nested `webhooks` was never delivered and is retired (ADR-0049) | | Do you need advanced authentication (OAuth2, SAML)? | **Yes** → L3 (Connector) | -| Do you need retry policies and circuit breaking? | **Retry: yes, L3.** `retryConfig` is executed at the platform's one outbound call (ADR-0049 ruled `实现`) — backoff shape, attempt count, retryable statuses, network-error retry and a per-attempt `requestTimeoutMs`. **Circuit breaking: no level provides it** — every `health.circuitBreaker` sub-key is still `dead` in `packages/spec/liveness/connector.json` and no breaker ever opens; implement it in the connector provider. Outbound **rate limiting** is not a reason to pick any level either: no level provides it (#4911); throttle at the provider or gateway | +| Do you need retry policies and circuit breaking? | **Retry: yes, L3.** `retryConfig` is executed at the platform's one outbound call (ADR-0049 ruled `实现`) — backoff shape, attempt count, retryable statuses, network-error retry and a per-attempt `requestTimeoutMs`. **Circuit breaking: no level provides it** — `health.circuitBreaker` was retired (ADR-0049) because no breaker ever opened; implement it in the connector provider or an upstream gateway. Outbound **rate limiting** is not a reason to pick any level either: no level provides it (#4911); throttle at the provider or gateway | | Is it a simple point-to-point sync with an external system? | **Yes** → L3 (Connector) with `syncConfig` | | Are you building a data warehouse pipeline? | The extraction half is L3 (`syncConfig`); the warehouse-side transformation is the warehouse's own tooling. There is no ObjectStack pipeline protocol (#6414) | | Are you integrating with an enterprise system? | **Yes** → L3 (Connector) | @@ -409,7 +408,7 @@ mostly answers "which surface", and — for the two questions that used to route ``` ObjectStack ↔ Enterprise Connector ↔ SAP ↓ - Webhooks, Auth, Retry / Circuit Breaker + Auth, Retry ``` Use **L3 Enterprise Connector** for production-grade integrations — including straightforward point-to-point sync, via a connector instance with simple `auth` diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 1b20fc41134..1821930c951 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`. ⚠️ 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 | +| connector | seeded 2026-09-17 (#18582) — the second of the three `PENDING_GOVERNANCE` debts #18133 declared, paid in the same diff as `analytics_cube`, which empties that map. Not a registered kind: bound in `UNREGISTERED_KIND_SCHEMAS` (#6245) and reached through `getMetadataTypeSchema`'s unregistered-kind fallback. **What the walk actually resolves, measured:** the binding names `DeclarativeConnectorEntrySchema`. ⚠️ The MECHANISM changed with the `connectionTimeoutMs` retirement and the prior sentence here is corrected rather than carried: that schema USED TO BE `ConnectorSchema.superRefine(...)`, a Zod 4 check attached to the same object def, and the key-set conclusion used to rest on that attachment. It is now a `z.preprocess` PIPE — both published carriers wrap one shared private `ConnectorBaseSchema` in the ADR-0049 retired-default residue stage, the entry schema adding the ADR-0097 cross-field rules on the base before wrapping, so the two are SIBLINGS rather than parent and child, and what preserves the walked shape is the pipe's read-through `shape`, NOT a `superRefine` attachment. The CONCLUSION is unchanged and re-measured on the built entry rather than inherited: both carriers expose 30 keys and the key sets are byte-identical, with no entry-only and no base-only key. The gate cannot tell the two schemas apart; what the entry schema buys is REFUSALS, invisible to the walk and visible only in the three rows where they are the whole verdict. **ONE SCHEMA, TWO DOORS** is the shape fact behind the 29/1/30 split (live/planned/dead; counts read from the generated `state-counts.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 30 `dead`, re-measured at this head and partitioned so every row is counted exactly once: two declared subsystems with no engine — `syncConfig` (8), `fieldMappings` (7) — plus `triggers` (6, and the schema's own docblock says so: #3197), `metadata`, `actions.description`/`.outputSchema`, and the six top-level `retiredKey` tombstones `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status` and `webhooks`. That sums to 30, the dead count the generated `state-counts.md` row carries. ⚠️ It was 44 until the connector resilience family was retired (ADR-0049): `health` counted 15 drilled rows (both sub-blocks plus the `monitoringWindow` tombstone) and is now ONE leaf tombstone row — the gate refuses `children` under a property that is no longer a container — and `webhooks` left the undrilled baseline for the same reason; `status` and `webhooks` stayed one row each and changed only from dead-awaiting-a-decision to dead-and-tombstoned. ⚠️ `retryConfig` IS NO LONGER IN THIS LIST: all eight of its sub-keys went `live` when #18975 made the declared policy execute at the one platform fetch site, which is the same measurement the falsification note at the end of this row records — so a reader who still finds "`retryConfig` (8)" among the dead is reading a stale copy. ⚠️ Nor is it "the two timeouts" any more: `requestTimeoutMs` is `live` (it becomes `resilientFetch`'s per-attempt deadline) and `connectionTimeoutMs` is the retired tombstone named above. ⭐ EIGHT rows in this ledger are `retiredKey` tombstones that keep their rows because the key stays in the walked shape (the `rls.priority` precedent) — `rateLimitConfig`, `errorMapping`, `connectionTimeoutMs`, `health`, `status`, `webhooks`, `fieldMappings.transform` and `triggers.interval` — but ⛔ that eight is NOT a separate addend: the first six ARE the top-level tombstones counted above and the last two are already inside the `fieldMappings` and `triggers` counts, which is exactly the double-count that made the previous "and four `retiredKey` tombstones" tail drift. (`health.circuitBreaker.monitoringWindow` was the ninth until its block left whole with `health`.) Count them by name, never by adding the tail. **A prior in-repo claim is recorded here with its DIRECTION measured rather than remembered, because this row's job is the history of how the type got here**: the conversion registry's note inside `connector-rate-limit-config-removed`'s fixture reads "`retryConfig` and the timeouts beside it are untouched by THIS conversion — a statement about its scope, not a liveness verdict. They are not live: declared, defaulted and documented, and read by nothing." ⚠️ It asserts they are NOT live, and it scopes "untouched" to that one conversion. The former tail here quoted it as asserting the OPPOSITE ("they are live") and called it false when seeded — an inversion that turned this whole passage upside down, and it is corrected rather than carried. Measured direction: the note was TRUE when this ledger was seeded (2026-09-17) and is STALE now, #18975 having made the declared policy execute at the one platform fetch site (`connectorFetchOptions` → `resilientFetch`), so `retryConfig`'s eight sub-keys are `live` on their own rows and `requestTimeoutMs` is `live` beside them; only `connectionTimeoutMs` still answers to it, as the retired tombstone. ⛔ The stale comment is not rewritten from here — it is #19729's, as a dated note beside it — and it is not a line this PR's diff touches. ⚠️ The seeding note's supporting census — "the word does not occur outside `packages/spec` at all" — is FALSE at this head and is corrected rather than carried: `git grep -n retryConfig 14fdebd766 -- . ':!packages/spec'` returns 67 **matching lines** over 15 files — `git grep -o` on the same tree and pathspec returns 77 **occurrences**, and a line is not an occurrence, which is the trap a re-measurer falls into next (26 matching lines in the materializer `packages/services/service-automation/src/plugin.ts` and its materialization test, 22 across `connector-rest` and `connector-openapi` — providers, connectors and their tests — 13 in five `.changeset` fragments, and 6 on two `content/docs` pages). ⛔ Re-read that as the standing lesson of this row: a census is a count plus the tree it was taken against, and a bare "does not occur" with no commit behind it is the shape that rots first. The timeouts half is settled on its own rows: `requestTimeoutMs` is `live`, `connectionTimeoutMs` is retired | | 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 193ad584b19..4c0f83d58fa 100644 --- a/packages/spec/liveness/connector.json +++ b/packages/spec/liveness/connector.json @@ -230,8 +230,8 @@ }, "webhooks": { "status": "dead", - "verifiedAt": "2026-09-17", - "note": "A connector's NESTED webhook array is not the collection the dispatcher reads, and nothing else reads it either. `bootstrapDeclaredWebhooks` (packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes `sys_webhook` rows from `readDeclared(engine, metadataService, 'webhook')` — metadata ITEMS of type `webhook`, which the decomposition registers from the TOP-LEVEL `webhooks:` stack collection (`METADATA_ARRAY_KEYS` in packages/objectql/src/engine.ts, and `webhooks: 'webhook'` in packages/metadata/src/plugin.ts). `connectors: 'connector'` in that same map registers a connector entry WHOLE, so the array nested inside it never becomes a `webhook` item and never reaches the materializer. Census: the only `.webhooks` reads outside packages/spec are of `stack.webhooks` — packages/lint/src/validate-functional-completeness.ts (`for (const hook of entriesOf(stack.webhooks))`) and the bootstrap's own docblock — with no read of a connector's own array anywhere. ⚠️ That docblock says it materializes each 'stack/connector-authored webhook'; the phrase is not backed by its code on this checkout, and it is what makes this key look live. ⛔ ADR-0049 owes a decision rather than a sweep — either decompose a connector's webhooks into `webhook` items, or retire the key. WHY ONE VERDICT COVERS THE SUBTREE, declared rather than assumed: the coordinate is recorded in scripts/liveness/undrilled-containers.baseline.json. `WebhookConfigSchema` is `WebhookSchema.extend({ events, signatureAlgorithm })`, so a `deferred` row pointing at the governed `webhook` type would be refused by the gate's key-set EQUALITY check — correctly, because the extension adds two keys the target does not classify. Drilling would mean writing 21 child rows of which 8 are the ADR-0010 protection envelope the gate auto-classifies `live` everywhere else and 13 would repeat this one sentence: fabricated granularity over a container that is dead as a whole. The recorded row is the honest form, and it leaves the baseline as soon as the decision above is taken." + "verifiedAt": "2026-09-27", + "note": "RETIRED 2026-09-27 (ADR-0049 enforce-or-remove; the connector resilience family) — the `retire` arm of the decision this row asked for. Tombstoned with `retiredKey` (non-strict schema, ADR-0104), carried by `DeclarativeConnectorEntrySchema` too; the nested shape left whole (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). Stripped — never moved — from sources and stored rows by the protocol-18 conversion `connector-resilience-keys-removed`: moving a nested webhook into the top-level collection would START deliveries it never made, which is the author's call (the D3 entry `connector-resilience-keys-retired`). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent); it is a LEAF now, so its `connector/webhooks` row in scripts/liveness/undrilled-containers.baseline.json was deleted (the gate reported it stale once the container stopped being one). Why, unchanged from the prior verdict: a connector's NESTED webhook array is not the collection the dispatcher reads — `bootstrapDeclaredWebhooks` (packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes `sys_webhook` rows only from metadata ITEMS of type `webhook`, which the decomposition registers from the TOP-LEVEL `webhooks:` stack collection, and `connectors: 'connector'` registers a connector entry WHOLE; `bootstrap-declared-webhooks.connector-nested.test.ts` pins that the nested array is not hoisted. Declare a webhook in the top-level `webhooks:` collection. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "rateLimitConfig": { "status": "dead", @@ -312,8 +312,8 @@ }, "status": { "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Declared `ConnectorStatusSchema` with a `.default('inactive')`, and dispatched on by nothing — no consumer reads `def.status` in this repo, and the runtime's answer to 'can this connector be dispatched?' is a DIFFERENT field: `RegisteredConnector.state` (`ready` / `degraded`, #3017), which `getConnectorDescriptors` publishes as `state` and which no authored value can set. The two names one letter apart on one wire payload are the trap: `status: 'active'` on an authored entry neither enables nor advertises anything, while `state` — the field a reader will actually see — is computed. The keys that DO decide participation are `enabled` (materialization + the #2612 audit opt-out) and `provider`. ⛔ ADR-0049 owes a decision: retire it, or make it the authored half of the dispatchability answer." + "verifiedAt": "2026-09-27", + "note": "RETIRED 2026-09-27 (ADR-0049 enforce-or-remove; the connector resilience family) — the decision this row asked for, taken as `retire`. Tombstoned with `retiredKey` because `ConnectorSchema` is not `.strict()` and a plain delete would be a silent strip (ADR-0104); carried by `DeclarativeConnectorEntrySchema` too, so `stack.connectors[]` and `/meta/connector` refuse it; `ConnectorStatus` left whole. Its `.default('inactive')` was materialized by every 17.x parse, so `status: 'inactive'` joined `connectionTimeoutMs: 30000` in the retired-default residue stage (accepted and STRIPPED; every other value keeps the refusal). Stripped from sources and stored rows by the protocol-18 conversion `connector-resilience-keys-removed`. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). What the prior verdict recorded still holds and is why: no consumer read `def.status` — the runtime's dispatchability answer is `RegisteredConnector.state` (`ready` / `degraded`, #3017), computed and published by `getConnectorDescriptors`, one letter away on the same payload. The only non-spec occurrences were WRITES read back by nothing — `status: 'active'` in the four shipped connector packages and `status: 'error'` on the automation service's degraded husk — deleted in the same change. Participation is `enabled` (and `provider`). The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "enabled": { "status": "live", @@ -328,91 +328,9 @@ "note": "RETIRED (ADR-0049 enforce-or-remove) — eleven authorable keys (`ErrorMappingConfig` ×4, `ErrorMappingRule` ×7) that nothing in the tree ever read, one of them spelled `userMessage`, the name of the LIVE API-error channel, so writing a rule here validated, published and showed nobody anything. Tombstoned with `retiredKey` because `ConnectorSchema` is not `.strict()` and a plain delete would be a silent strip (ADR-0104); the tombstone is inherited by `DeclarativeConnectorEntrySchema`, 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:errorMapping` and `integration/DeclarativeConnectorEntry:errorMapping` in `RETIRED_KEYS_BY_MAJOR[18]`; sources are rewritten by the D2 conversion `connector-error-mapping-removed`. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "health": { - "children": { - "healthCheck": { - "children": { - "enabled": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "No connector health-check loop exists. Census: the only `healthCheck` occurrences outside packages/spec are the KERNEL's own plugin health contract (`packages/core/src/health-monitor.ts`, `PluginHealthCheck`) — a different shape on a different subject — and nothing in packages/connectors or packages/services/service-automation polls a connector endpoint, counts consecutive failures, or acts on a threshold. SYNC_ARCHITECTURE.md ticks '✅ Monitoring: Health checks, metrics, logging' at L3; that tick is not backed on this surface. ⛔ ADR-0049 owes a decision rather than a sweep. The sub-keys below are dead for this one reason." - }, - "intervalMs": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled` — nothing schedules the probe." - }, - "timeoutMs": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." - }, - "endpoint": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled` — no request is ever made to it." - }, - "method": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." - }, - "expectedStatus": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." - }, - "unhealthyThreshold": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." - }, - "healthyThreshold": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.healthCheck.enabled`." - } - } - }, - "circuitBreaker": { - "children": { - "enabled": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "No circuit breaker exists for connectors. Census: nothing outside packages/spec reads `circuitBreaker` except the protocol-18 rename conversion in packages/spec/src/conversions/registry.ts, which rewrites the key's NAME and is not a consumer. No state machine opens, half-opens or closes anything, and no call path consults a breaker before dispatching a connector action. The nearest real mechanism is the ADR-0097 DEGRADED instance (#3017) — a husk registered with `state: 'degraded'` and a backoff retry — which is driven by materialization failures and reads none of these keys. ⛔ ADR-0049 owes a decision rather than a sweep. Sub-keys below are dead for this one reason." - }, - "failureThreshold": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." - }, - "resetTimeoutMs": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." - }, - "halfOpenMaxRequests": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`." - }, - "monitoringWindowMs": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`. Renamed from `monitoringWindow` by the protocol-18 conversion (#15680/#14478) so the unit lives in the key name — an honesty fix to a declaration that is still unread." - }, - "monitoringWindow": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "REMOVED (#15680, ruling B on #14478) — tombstoned at the schema with `retiredKey`, which carries the prescription and makes authoring it a tsc error and a parse error; sources are renamed by the protocol-18 conversion `connector-duration-unit-suffixes`. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). Use `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. The tombstone is packages/spec/src/integration/connector.zod.ts#CircuitBreakerConfigSchema." - }, - "fallbackStrategy": { - "status": "dead", - "verifiedAt": "2026-09-17", - "note": "Dead for the one reason recorded on `health.circuitBreaker.enabled`. `cache` / `default_value` / `error` / `queue` name four behaviours none of which is implemented anywhere — the shape this ledger exists to find." - } - } - } - } + "status": "dead", + "verifiedAt": "2026-09-27", + "note": "RETIRED 2026-09-27 (ADR-0049 enforce-or-remove; the connector resilience family, one batch with `status` and `webhooks`) — tombstoned at the schema with `retiredKey` (the prescription names the probe and breaker keys, including `circuitBreaker.monitoringWindowMs` and the pre-rename `monitoringWindow`; authoring it is a tsc error and a parse error) and stripped from sources and stored rows by the protocol-18 conversion `connector-resilience-keys-removed`. The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent). ⚠️ It is a LEAF now: the fourteen per-key rows that were drilled under `healthCheck` / `circuitBreaker` (all `dead`, verified 2026-09-17) left with the subtree — the gate refuses `children` on a property that is not a container (measured: `connector/health (declared children but property is not a container)`), so keeping them would not be possible, let alone honest. Their verdicts are carried here, one reason each: no connector health-check loop exists — nothing polls a connector endpoint, counts consecutive failures or acts on `unhealthyThreshold` / `healthyThreshold`, and the only `healthCheck` code outside packages/spec is the KERNEL's plugin health contract (`packages/core/src/health-monitor.ts`), a different shape on a different subject; and no circuit breaker exists for connectors — no state machine opens, half-opens or closes anything, no call path consults a breaker before dispatching, and none of the four `fallbackStrategy` behaviours is implemented. Census at origin/main 3f86dc52f2: zero reads of any of the fourteen keys outside packages/spec, with `retryConfig` — the executed sibling policy — read 17 times in packages/connectors + packages/services/service-automation by the same scan. SYNC_ARCHITECTURE.md's '✅ Monitoring: Health checks' tick was dropped with it. Implement probes and circuit breaking in the connector provider or an upstream gateway; dispatchability is the computed `state` (`ready` / `degraded`) on `GET /api/v1/automation/connectors`. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "metadata": { "status": "dead", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 03874903b7e..c07e660689d 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -65,6 +65,6 @@ for both corollaries. | `rest_api` | 12 | 0 | 0 | 14 | 0 | 26 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | -| `connector` | 29 | 0 | 0 | 44 | 1 | 74 | +| `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **931** | **5** | **1** | **168** | **11** | **1116** | +| **total** | **931** | **5** | **1** | **154** | **11** | **1102** | diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index 921eaf1bfe8..11deb64086b 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -70,7 +70,6 @@ "app/branding", "app/contextSelectors.optionsSource", "book/groups.pages", - "connector/webhooks", "dashboard/dateRange", "dashboard/globalFilters", "dashboard/header", diff --git a/packages/spec/src/api/rest-server.test.ts b/packages/spec/src/api/rest-server.test.ts index d83ec5039a8..31c2771b7eb 100644 --- a/packages/spec/src/api/rest-server.test.ts +++ b/packages/spec/src/api/rest-server.test.ts @@ -803,8 +803,11 @@ describe('[#4579] the OpenApi31 block schemas are not exported from any entry po }); // v17 dual-source cleanup (#4572): the bare names WebhookEvent(Schema) / - // WebhookConfig(Schema) belong to @objectstack/spec/integration alone - // (connector event enum + connector webhook config). The ./api pair was the + // WebhookConfig(Schema) belonged to @objectstack/spec/integration alone + // (connector event enum + connector webhook config — both since retired with + // the connector-nested `webhooks`, ADR-0049, so today NO entry publishes + // them; `integration/connector-resilience-keys-retirement.test.ts` pins + // that). The ./api pair was the // #4411-style trap: same names, different concepts, different forms // (z.object here vs z.enum there). WebhookConfig(Schema) on ./api was dead // and removed; WebhookEvent(Schema) was first renamed OpenApiWebhookEvent(Schema) diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index b3bb03de334..55846752d5a 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -67,7 +67,9 @@ export type WebhookTriggerType = z.input; * webhooks re-seed every boot as `managed_by: 'package'`, but a row an admin has * edited in Setup (`customized: true`) is never clobbered — a deactivated noisy * webhook survives redeploys. Authoring `webhooks:` is therefore live, not a - * no-op. (Connector `webhooks` remain NOT-yet-enforced — see #3197.) + * no-op. (A connector's NESTED `webhooks` array never was — it was never + * registered as a `webhook` item — and was retired under ADR-0049; declare + * every webhook here.) * * **NAMING CONVENTION:** * Webhook names are machine identifiers and must be lowercase snake_case. @@ -164,21 +166,15 @@ export const WebhookSchema = lazySchema(() => strictObject({ 'Until this shape was closed, these were dropped silently — the webhook still parsed and still ' + 'materialized, so a subscription scoped or secured with a key we do not declare ' + 'shipped listening to the wrong thing, or to everything.', - // `WebhookConfigSchema` (integration/connector.zod.ts) is this shape - // `.extend()`ed, and zod carries BOTH the strictness and this error map onto - // the extension — verified against real zod, not assumed. Naming the - // extension's own key keeps a typo of it fixable on that surface. - // - // Its SIBLING key `events` is deliberately NOT listed here even though the - // extension declares it, because it is an alias target above: listing it - // would make a base-surface typo suggest `events`, and writing `events` would - // then suggest `triggers` — an author walked through two rejections to a key - // the base does not accept. That is finding 7 (the `triggerPhrase` → - // `triggerPhrases` → tombstone chain) arriving from a new direction: - // `acceptsNothing` guards the shape-derived candidates, and nothing guards - // hand-written `extraKeys`. On the connector surface `events` is declared, so - // it is never the unrecognized key there anyway. - extraKeys: ['signatureAlgorithm'], + // No `extraKeys`. This shape used to name `signatureAlgorithm` here because + // `WebhookConfigSchema` (integration/connector.zod.ts) `.extend()`ed it, and a + // typo of the extension's own key was worth a suggestion on that surface. The + // extension was retired with the connector-nested `webhooks` (ADR-0049), so + // no surface accepts `signatureAlgorithm` any more — keeping it would point a + // typo on THIS shape at a key this shape refuses, the finding-7 chain + // (`triggerPhrase` → `triggerPhrases` → tombstone) that `acceptsNothing` + // guards for shape-derived candidates and nothing guards for hand-written + // `extraKeys`. }, { // [#8554] "unique per organization", not bare "unique". This `describe()` is // the SOURCE of the generated reference page diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 65b50b25250..80ce1644095 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9417,55 +9417,41 @@ const dashboardRefreshIntervalToRefreshIntervalSeconds: MetadataConversion = { }; /** - * The two connector duration keys whose name carried no unit → suffixed - * (protocol 18, #15680 for #14478): `health.circuitBreaker.monitoringWindow` → - * `monitoringWindowMs`, and `triggers[].interval` → `intervalSeconds`. - * - * One entry because they are one authored document and one authoring session — - * a connector and the resilience block that guards it. The circuit-breaker case - * is the sharpest in this card: `monitoringWindow` (ms) sat ONE key below - * `resetTimeoutMs`, which already spelled its unit, so a single six-key shape - * carried both conventions and a reader had no rule to apply, only two examples - * that disagreed. The trigger case is the widest: the bare token `interval` - * means MILLISECONDS elsewhere in this same spec, so the identical spelling - * carried two units a thousandfold apart. + * The connector duration key whose name carried no unit → suffixed (protocol + * 18, #15680 for #14478): `triggers[].interval` → `intervalSeconds`. The bare + * token `interval` means MILLISECONDS elsewhere in this same spec, so the + * identical spelling carried two units a thousandfold apart. + * + * ⚠️ This entry used to carry a SECOND rename, `health.circuitBreaker. + * monitoringWindow` → `monitoringWindowMs` — the sharpest case of that card: + * `monitoringWindow` (ms) sat ONE key below `resetTimeoutMs`, which already + * spelled its unit. That half was ABSORBED by `connector-resilience-keys-removed` + * (ADR-0049 enforce-or-remove, the same unreleased protocol step): the whole + * `health` block left the schema, so a breaker key renamed here would be + * stripped by the removal immediately after — a composition with no observable + * rename — and the table's disjoint-fixture contract cannot hold a fixture + * whose `health` block another entry deletes (`spec-property-retirement` §0, + * the `agent.knowledge` precedent). An author who still holds either spelling + * is served by the removal: its notice names `health`, and the tombstone's + * prescription names both `monitoringWindow` and `monitoringWindowMs`. The id + * keeps its original spelling on purpose — ids are how the chain, the step + * list and every published changelog refer to an entry. * * A published connector row lands whole in `sys_metadata` (`ConnectorSchema`'s * own docblock says so, which is why #7990 forbids inline secrets on it), so - * the chain has a seam that sees both keys — hence a conversion rather than the - * semantic entries this card's two runtime-emitted keys took. - * - * The two are walked in one pass but emit SEPARATELY: a connector may author - * either, both, or neither, and an operator reading the notice list needs to see - * which of its own keys moved. Retired from the load path, tombstoned at the - * schema, replayable here. + * the chain has a seam that sees the key — hence a conversion. Retired from the + * load path, tombstoned at the schema, replayable here. */ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { id: 'connector-health-and-trigger-durations-unit-in-key', toMajor: 18, retiredFromLoadPath: true, - surface: 'connector.health.circuitBreaker.monitoringWindow, connector.triggers[].interval', - summary: "connector keys 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs' and 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; both values are unchanged)", + surface: 'connector.triggers[].interval', + summary: "connector key 'triggers[].interval' → 'intervalSeconds' (#14478 — the unit lived only in the description; the value, seconds, is unchanged. The breaker half, 'health.circuitBreaker.monitoringWindow' → 'monitoringWindowMs', was absorbed by the removal of the whole 'health' block)", apply(stack, emit) { return mapCollection(stack, 'connectors', (connector, path) => { let next = connector; - const health = next.health; - if (isDict(health)) { - const breaker = health.circuitBreaker; - if (isDict(breaker)) { - const renamedBreaker = renameKey(breaker, 'monitoringWindow', 'monitoringWindowMs'); - if (renamedBreaker) { - emit({ - from: 'monitoringWindow', - to: 'monitoringWindowMs', - path: `${path}.health.circuitBreaker.monitoringWindowMs`, - }); - next = { ...next, health: { ...health, circuitBreaker: renamedBreaker } }; - } - } - } - const triggers = next.triggers; if (Array.isArray(triggers)) { let triggersChanged = false; @@ -9494,16 +9480,15 @@ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { name: 'billing_api', label: 'Billing API', type: 'rest', - health: { - circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindow: 120000 }, - }, + // No `health` block: `connector-resilience-keys-removed` strips it + // whole, so it may not appear in this fixture (disjointness, §3). triggers: [ { key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }, // A webhook trigger authors no interval and keeps its identity. { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, ], }, - // A connector that authored neither key keeps its identity (copy-on-write). + // A connector that authored no trigger keeps its identity (copy-on-write). { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, ], }, @@ -9513,9 +9498,6 @@ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { name: 'billing_api', label: 'Billing API', type: 'rest', - health: { - circuitBreaker: { enabled: true, resetTimeoutMs: 30000, monitoringWindowMs: 120000 }, - }, triggers: [ { key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }, { key: 'invoice_paid', label: 'Invoice paid', type: 'webhook' }, @@ -9524,7 +9506,99 @@ const connectorHealthAndTriggerDurationsUnitInKey: MetadataConversion = { { name: 'crm_catalog', label: 'CRM catalog', type: 'rest' }, ], }, - expectedNotices: 2, + expectedNotices: 1, + }, +}; + +/** + * `connector.health`, `connector.status` and `connector.webhooks` removed + * (protocol 18 — ADR-0049 enforce-or-remove, one batch for the family: the + * mainstream connector surface offers none of the three as author metadata, and + * what it does offer is already delivered here by other keys). + * + * Sixteen authorable keys, measured with zero reads outside `packages/spec`: + * the `health.healthCheck` probe (eight keys) and `health.circuitBreaker` (six) + * had no engine — nothing polled, counted consecutive failures or tripped a + * breaker; `status` was read by nothing (the runtime publishes a COMPUTED + * `state`, and participation is `enabled`); and a webhook nested in a + * connector was never registered as a `webhook` item, so it was never + * materialized into `sys_webhook` or delivered. + * + * A pure lossless delete, one notice per stripped key: none of the three ever + * had an effect to preserve. In particular the nested `webhooks` are STRIPPED, + * never MOVED to the top-level `webhooks:` collection — moving them would start + * deliveries this connector never made, which is an author's decision, not a + * mechanical repair (the family's D3 entry, `connector-resilience-keys-retired`, + * carries that judgement). The whole `health` block goes as one key, so this + * entry also serves an author still holding the pre-rename + * `circuitBreaker.monitoringWindow` spelling — the rename's breaker half was + * absorbed here (see `connector-health-and-trigger-durations-unit-in-key`). + * + * `retiredFromLoadPath`: `ConnectorSchema` tombstones all three keys + * (`retiredKey`, tsc `never` + the parse-time prescription), so a live parse + * refuses loudly. This entry exists because a stored connector row CAN carry + * them — the `PUT /meta/connector/:name` door persisted what it parsed, + * including the materialized `status: 'inactive'` default — and the + * rehydration seam `applyConversionsToStoredItem('connector', row)` is live for + * this type; so 17.x rows replay clean, and `os migrate meta --from 17` lists + * the mechanical edits for author sources. + */ +const connectorResilienceKeysRemoved: MetadataConversion = { + id: 'connector-resilience-keys-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'connector.health / connector.status / connector.webhooks', + summary: + "connector keys 'health', 'status' and 'webhooks' removed (ADR-0049 — no connector health " + + 'probe or circuit breaker ever ran, nothing read an authored status (the runtime reports a ' + + 'computed `state`), and a webhook nested in a connector was never registered or delivered. ' + + 'The ConnectorHealth / HealthCheckConfig / CircuitBreakerConfig, ConnectorStatus and ' + + 'WebhookConfig / WebhookEvent / WebhookSignatureAlgorithm shapes went with them)', + apply(stack, emit) { + return mapCollection(stack, 'connectors', (c, path) => + stripKeys(c, ['health', 'status', 'webhooks'], emit, path)); + }, + fixture: { + before: { + connectors: [ + { + name: 'erp_gateway', + label: 'ERP Gateway', + type: 'api', + // The measured shape: both resilience blocks, including the + // pre-rename `monitoringWindow` spelling this removal absorbed. + health: { + healthCheck: { enabled: true, intervalMs: 30000, endpoint: '/health', method: 'GET' }, + circuitBreaker: { enabled: true, failureThreshold: 5, monitoringWindow: 120000, fallbackStrategy: 'cache' }, + }, + status: 'active', + webhooks: [{ + name: 'erp_order_created', + url: 'https://example.invalid/erp/orders', + object: 'order', + triggers: ['create'], + events: ['sync.completed'], + signatureAlgorithm: 'hmac_sha512', + }], + }, + // A stored 17.x row: the parse that wrote it materialized the + // `'inactive'` default, and nothing else of this family. + { name: 'hr_feed', label: 'HR Feed', type: 'saas', status: 'inactive' }, + // A connector that never authored any of the three keeps its identity — + // the copy-on-write contract `stripKeys` / `mapCollection` are built on. + { name: 'crm_directory', label: 'CRM Directory', type: 'saas' }, + ], + }, + after: { + connectors: [ + { name: 'erp_gateway', label: 'ERP Gateway', type: 'api' }, + { name: 'hr_feed', label: 'HR Feed', type: 'saas' }, + { name: 'crm_directory', label: 'CRM Directory', type: 'saas' }, + ], + }, + // Three from `erp_gateway` (health, status, webhooks — the nested keys + // leave with their block and are not counted), one from `hr_feed`. + expectedNotices: 4, }, }; @@ -10998,6 +11072,9 @@ export const CONVERSIONS_BY_MAJOR: Readonly { - const message = render(results.get('webhook-retry-policy')!); - expect(message).toContain('TS2353'); - expect(message).toContain('retryPolicy'); + it('`webhooks` itself is retired — the key no longer type-checks, whatever it holds', () => { + // Same compile-channel shape as the `transform` pair above: a `retiredKey()` + // input type is `undefined`, so tsc refuses without naming the key; the + // parse channel below carries the prescription. + const message = render(results.get('webhooks-retired')!); + expect(message).toContain('TS2322'); + expect(message).toContain("not assignable to type 'undefined'"); }); - it('…while the canonical spellings of all three compile', () => { + it('…while the canonical spelling of the surviving key compiles', () => { expect(render(results.get('canonical-control')!)).toBe(''); }); }); @@ -346,20 +348,29 @@ describe('[#5515] the schema rejects them at RUNTIME too, and how it says so', ( // they get it wrong is the difference between a fixable mistake and a // mysterious one — and the three keys are told three different ways. - it('`retryPolicy` is a curated tombstone: the rejection names #3494 and says there is no replacement', () => { - // A KEY verdict on a `strictObject` surface, so the assertion is about - // `unrecognized_keys` and the guidance text attached to it. - const result = WebhookConfigSchema.safeParse({ - name: 'order_created_webhook', - url: 'https://api.objectstack.com/webhooks/sap/orders', - events: ['record.created'], - retryPolicy: { maxRetries: 3, backoffStrategy: 'exponential', initialDelayMs: 1000 }, + it('`webhooks` on a connector is a KEY verdict carrying the retirement prescription', () => { + // Was: "`retryPolicy` is a curated tombstone" on the nested webhook shape + // (`WebhookConfigSchema`). That shape left with `connector.webhooks` + // (ADR-0049), so the example's webhook block is refused one level up, by + // the key — and the prescription must send the author to the collection + // that IS delivered, not merely refuse. (`retryPolicy`'s own curated + // tombstone lives on the delivered `WebhookSchema` and is pinned there.) + const result = ConnectorSchema.safeParse({ + name: 'sap_erp_connector', + label: 'SAP ERP Integration', + type: 'saas', + webhooks: [{ + name: 'order_created_webhook', + url: 'https://api.objectstack.com/webhooks/sap/orders', + events: ['record.created'], + }], }); expect(result.success).toBe(false); - const issues = result.error!.issues; - expect(issues[0]!.code).toBe('unrecognized_keys'); - expect(issues[0]!.message).toContain('`retryPolicy` was removed'); - expect(issues[0]!.message).toContain('There is no replacement'); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'webhooks'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.message).toMatch(/`connector\.webhooks` was removed/); + expect(issue!.message).toContain('top-level `webhooks:` collection'); }); it('`sourceField` / `targetField` are STRIPPED, and the mapping then fails on the missing canonical keys', () => { @@ -468,7 +479,8 @@ describe('[#5515] the bare `Connector` is the author shape; `ConnectorParsed` is const message = render(results.get('parsed-connector')!); // TS2739 on the innermost mismatch first: the parse supplies `direction`, // `realtimeSync`, `conflictResolution`, `batchSize`, `deleteMode` under - // `syncConfig` (and `enabled` / `status` one level up); `z.infer` demands + // `syncConfig` (and `enabled` one level up — `status` was one too, until + // ADR-0049 retired it); `z.infer` demands // them all of the author. expect(message).toMatch(/TS2739: .* is missing the following properties/); expect(message).toContain('direction'); @@ -489,6 +501,8 @@ describe('[#5515] the bare `Connector` is the author shape; `ConnectorParsed` is expect(parsed.syncConfig!.direction).toBe('import'); expect(parsed.syncConfig).not.toHaveProperty('schedule'); expect(parsed.enabled).toBe(true); - expect(parsed.status).toBe('inactive'); + // `status` used to be supplied here as `'inactive'`; the key is retired + // (ADR-0049) and a parse no longer emits it. + expect(parsed).not.toHaveProperty('status'); }); }); diff --git a/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts new file mode 100644 index 00000000000..7927408040a --- /dev/null +++ b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts @@ -0,0 +1,540 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The connector resilience family RETIRED — `connector.health` (the + * `healthCheck` probe and the `circuitBreaker`), `connector.status` and the + * connector-nested `webhooks`: sixteen authorable keys, ADR-0049 + * enforce-or-remove, one batch. + * + * The maintainer's criterion for a declared-but-unenforced family decided it: + * does the mainstream platform offer the capability? Author-configured probes + * and breakers are not connector metadata anywhere in the mainstream (breakers + * live in API-gateway infrastructure); an authored `status` and a nested + * webhook array duplicate what is delivered here by other means (`enabled` plus + * the computed `state`; the top-level `webhooks:` collection). Measured before + * the removal: zero reads of any of the sixteen keys outside `packages/spec`, + * each census beside a lit control (`retryConfig`, `requestTimeoutMs`, + * `stack.webhooks`). + * + * Bookkeeping shapes, pinned below: + * 1. Three `retiredKey()` tombstones on the non-strict `ConnectorBaseSchema` + * (a bare deletion would be a SILENT STRIP, ADR-0104), carried by both + * published carriers — `ConnectorSchema` and + * `DeclarativeConnectorEntrySchema` — so the refusal reaches + * `registerConnector`, `stack.connectors[]` and the `/meta/connector` + * door. Six `RETIRED_KEYS_BY_MAJOR[18]` rows (three keys × two defs). + * 2. `status` carried `.default('inactive')`, so its emitted default joins + * the retired-default residue stage (accepted and STRIPPED); every other + * value keeps the refusal. + * 3. Seven defs leave whole (`RETIRED_DEFS_BY_MAJOR[18]`). + * 4. The D2 conversion `connector-resilience-keys-removed` strips all three + * keys from `connectors[]` and stored rows, and ABSORBS the breaker half of + * the same step's duration rename: a source still holding + * `health.circuitBreaker.monitoringWindow` ends with no `health` at all. + * 5. The family's D3 entry `connector-resilience-keys-retired`, which names + * that chain. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface really has: refusal, the issue `code`, the `path` + * naming WHICH key refused, and the prescription text (where the wording is + * the contract, pin the wording). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { collectConversionNotices } from '../conversions/apply'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ObjectStackSchema } from '../stack.zod'; +import { EXPORT_ENTRY_POINTS, exportNamesOf, holdersOf } from '../../scripts/lib/export-origins-testkit'; +import { ConnectorSchema, DeclarativeConnectorEntrySchema, type Connector } from './connector.zod'; + +/** A well-formed catalog descriptor — every required key, none of the retired ones. */ +const WELL_FORMED = { + name: 'erp_gateway', + label: 'ERP Gateway', + type: 'api', +} as const; + +/** What an author could write under each retired key before the removal. */ +const AUTHORED = { + health: { + healthCheck: { enabled: true, intervalMs: 30000, endpoint: '/health', method: 'GET', unhealthyThreshold: 3 }, + circuitBreaker: { enabled: true, failureThreshold: 5, monitoringWindowMs: 60000, fallbackStrategy: 'cache' }, + }, + status: 'active', + webhooks: [{ name: 'erp_order_created', url: 'https://example.invalid/erp/orders', events: ['sync.completed'] }], +} as const; + +type RetiredKey = keyof typeof AUTHORED; +const RETIRED_KEYS = Object.keys(AUTHORED) as RetiredKey[]; + +/** Each key's prescription: named, dated to the npm major, and closed with the house sentence. */ +const PRESCRIPTION: Record = { + health: /^`connector\.health` was removed in @objectstack\/spec 17 \(ADR-0049/, + status: /^`connector\.status` was removed in @objectstack\/spec 17 \(ADR-0049/, + webhooks: /^`connector\.webhooks` was removed in @objectstack\/spec 17 \(ADR-0049/, +}; + +/** What each prescription must send the author to — a refusal alone teaches nothing. */ +const POINTS_AT: Record = { + health: ['connector provider or an upstream gateway', '`state`'], + status: ['`enabled: false`', '`state`'], + webhooks: ['top-level `webhooks:` collection'], +}; + +const MIGRATE_SENTENCE = + /Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\.$/; + +function issueAt(result: { success: boolean; error?: { issues: readonly { path: PropertyKey[]; code: string; message: string }[] } }, at: string) { + expect(result.success, `the parse must refuse \`${at}\``).toBe(false); + return result.error!.issues.find((i) => i.path.join('.') === at); +} + +describe('connector resilience family retirement — the tombstones', () => { + for (const key of RETIRED_KEYS) { + it(`REJECTS an authored \`${key}\` at path \`${key}\`, carrying the prescription`, () => { + const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, [key]: AUTHORED[key] }), key); + expect(issue, `the refusal must name \`${key}\``).toBeDefined(); + // The machine-readable half this surface has: a `retiredKey()` tombstone + // raises `invalid_type` from its `z.never()` — not `unrecognized_keys`, + // which would mean the key had simply vanished from a strict shape. + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual([key]); + expect(issue!.message).toMatch(PRESCRIPTION[key]); + for (const target of POINTS_AT[key]) expect(issue!.message).toContain(target); + expect(issue!.message).toMatch(MIGRATE_SENTENCE); + // Customer-facing text carries the ADR, never a tracker number. + expect(issue!.message).not.toMatch(/#\d{3,}/); + }); + } + + it('the second carrier, the /meta door and `stack.connectors[]` refuse all three — with controls', () => { + const door = getMetadataTypeSchema('connector'); + expect(door, 'no schema bound for `connector`').toBeDefined(); + for (const key of RETIRED_KEYS) { + const withKey = { ...WELL_FORMED, [key]: AUTHORED[key] }; + expect(issueAt(DeclarativeConnectorEntrySchema.safeParse(withKey), key), `entry refuses ${key}`) + .toBeDefined(); + // The registry lookup is the real `PUT /meta/connector/:name` entry point: + // a rebinding that pointed `connector` at some third shape would pass the + // pin above and still accept the key in production. + expect(door!.safeParse(withKey).success, `the /meta door refuses ${key}`).toBe(false); + const stack = issueAt(ObjectStackSchema.safeParse({ connectors: [withKey] }), `connectors.0.${key}`); + expect(stack, `the stack refusal must locate ${key}`).toBeDefined(); + expect(stack!.message).toMatch(PRESCRIPTION[key]); + } + // CONTROL: the same three doors accept the same connector WITHOUT the keys, + // so every refusal above is attributable to the retired key 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('an author holding EITHER breaker spelling is refused at `health`, told the renamed key is itself gone', () => { + // The chain the family D3 entry names: `monitoringWindow` was renamed to + // `monitoringWindowMs` earlier in the same protocol step, and the whole + // block is removed now. Both spellings must end at a refusal that names + // both — never at a prescription that sends the author to a key that is + // refused next. + for (const breaker of [{ enabled: true, monitoringWindow: 120000 }, { enabled: true, monitoringWindowMs: 120000 }]) { + const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, health: { circuitBreaker: breaker } }), 'health'); + expect(issue).toBeDefined(); + expect(issue!.message).toContain('`circuitBreaker.monitoringWindowMs`'); + expect(issue!.message).toContain('`monitoringWindow` spelling it was renamed from'); + expect(issue!.message).toContain('the renamed key is removed with the rest'); + } + }); + + it('parses a well-formed connector and grows none of the three properties', () => { + const parsed = ConnectorSchema.parse({ ...WELL_FORMED }); + expect(parsed.name).toBe('erp_gateway'); + // CONTROL: the live defaults 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. `status` used to be + // EMITTED here as `'inactive'` on every parse. + for (const key of RETIRED_KEYS) expect(parsed).not.toHaveProperty(key); + }); + + it("accepts and STRIPS `status: 'inactive'` — the retired default every 17.x parse emitted — on both carriers", () => { + for (const [label, schema] of [ + ['base', ConnectorSchema], + ['the /meta + stack.connectors carrier', DeclarativeConnectorEntrySchema], + ] as const) { + // The shape a 17.x parse produced for a three-key author literal, + // including the other retired default already in the stage. + const r = schema.safeParse({ ...WELL_FORMED, status: 'inactive', connectionTimeoutMs: 30000 }); + expect(r.success, `${label} must accept the emitted defaults as residue`).toBe(true); + if (!r.success) continue; + expect(r.data, `${label} must STRIP status`).not.toHaveProperty('status'); + expect(r.data, `${label} keeps stripping connectionTimeoutMs`).not.toHaveProperty('connectionTimeoutMs'); + // CONTROL: the live sibling on the same shape is untouched by the stage. + expect(r.data.enabled, `${label} keeps the live sibling`).toBe(true); + } + }); + + it('⛔ keeps the refusal for every status value that is NOT the retired default', () => { + for (const value of ['active', 'error', 'configuring', 'INACTIVE', ' inactive', false, null]) { + const issue = issueAt(ConnectorSchema.safeParse({ ...WELL_FORMED, status: value }), 'status'); + expect(issue, `${String(value)} must be refused AT the key`).toBeDefined(); + expect(issue!.message, `${String(value)} must carry the prescription`).toMatch(PRESCRIPTION.status); + } + }); + + it('the residue stage leaves the walked shape intact — the three tombstones stay walkable', () => { + // The authorable-surface and liveness walkers duck-test `.shape`; a wrapper + // that lost it would silently drop the 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(); + for (const key of RETIRED_KEYS) expect(Object.keys(shape!), `${label} keeps ${key} walkable`).toContain(key); + expect(Object.keys(shape!), `${label} keeps its live neighbours`).toContain('enabled'); + } + }); + + it('fails tsc at the authoring site: the input type of each key is `never`', () => { + const connector: Connector = { + ...WELL_FORMED, + // @ts-expect-error — `health` is a retiredKey() tombstone: its input type is `never`. + health: AUTHORED.health, + // @ts-expect-error — `status` is a retiredKey() tombstone: its input type is `never`. + status: 'active', + // @ts-expect-error — `webhooks` is a retiredKey() tombstone: its input type is `never`. + webhooks: AUTHORED.webhooks, + }; + // The parse channel agrees with the type channel on the same literal. + expect(ConnectorSchema.safeParse(connector).success).toBe(false); + }); +}); + +describe('connector resilience family retirement — the D2 conversion', () => { + it('a STORED connector row carrying the family replays clean through the rehydration seam', () => { + // The `PUT /meta/connector/:name` door persisted what it parsed — including + // the materialized `status: 'inactive'` — and `applyConversionsToStoredItem` + // is live for the `connector` type. Measured here rather than assumed. + const stored: Record = { ...WELL_FORMED, ...AUTHORED, 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)).toEqual([ + 'connector-resilience-keys-removed', + 'connector-resilience-keys-removed', + 'connector-resilience-keys-removed', + ]); + for (const key of RETIRED_KEYS) expect(rehydrated).not.toHaveProperty(key); + // CONTROL: the seam rewrote the retired keys and nothing else. + expect(rehydrated.requestTimeoutMs).toBe(12000); + expect(rehydrated.name).toBe('erp_gateway'); + // …and the result is exactly what the tombstoned door accepts. + expect(DeclarativeConnectorEntrySchema.safeParse(rehydrated).success).toBe(true); + }); + + it('strips the three keys from `connectors[]` — one attributed notice per key — and is idempotent', () => { + const { stack, notices } = collectConversionNotices( + { + connectors: [ + { ...WELL_FORMED, ...AUTHORED }, + // Never authored any of them: rides through untouched. + { name: 'crm_directory', label: 'CRM Directory', type: 'saas' }, + ], + }, + { includeRetired: true }, + ); + expect(stack).toEqual({ + connectors: [ + { ...WELL_FORMED }, + { name: 'crm_directory', label: 'CRM Directory', type: 'saas' }, + ], + }); + expect(notices.map((n) => [n.conversionId, n.path])).toEqual([ + ['connector-resilience-keys-removed', 'connectors[0].health'], + ['connector-resilience-keys-removed', 'connectors[0].status'], + ['connector-resilience-keys-removed', 'connectors[0].webhooks'], + ]); + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference (the copy-on-write contract). + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('never MOVES a nested webhook to the top-level collection — that would start deliveries', () => { + const { stack } = collectConversionNotices( + { connectors: [{ ...WELL_FORMED, webhooks: AUTHORED.webhooks }] }, + { includeRetired: true }, + ); + expect(stack).not.toHaveProperty('webhooks'); + expect(stack).toEqual({ connectors: [{ ...WELL_FORMED }] }); + }); + + it('the absorbed chain: a pre-rename breaker key ends with the whole block gone, and the rename no longer fires on it', () => { + const { stack, notices } = collectConversionNotices( + { + connectors: [{ + ...WELL_FORMED, + health: { circuitBreaker: { enabled: true, monitoringWindow: 120000 } }, + // The rename's surviving half, in the same connector, as the control: + // it must still fire — this retirement touched only the breaker half. + triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60 }], + }], + }, + { includeRetired: true }, + ); + expect(stack).toEqual({ + connectors: [{ + ...WELL_FORMED, + triggers: [{ key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60 }], + }], + }); + expect(notices.map((n) => [n.conversionId, n.from, n.to])).toEqual([ + ['connector-health-and-trigger-durations-unit-in-key', 'interval', 'intervalSeconds'], + ['connector-resilience-keys-removed', 'health', '(removed)'], + ]); + }); +}); + +describe('connector resilience family retirement — ADR-0087 registration', () => { + it('declares all six carrier keys and the seven removed defs under major 18', () => { + for (const def of ['integration/Connector', 'integration/DeclarativeConnectorEntry']) { + for (const key of RETIRED_KEYS) { + expect(RETIRED_KEYS_BY_MAJOR[18], `${def}:${key} must be declared`).toContain(`${def}:${key}`); + } + } + for (const def of [ + 'integration/ConnectorHealth', + 'integration/HealthCheckConfig', + 'integration/CircuitBreakerConfig', + 'integration/ConnectorStatus', + 'integration/WebhookConfig', + 'integration/WebhookEvent', + 'integration/WebhookSignatureAlgorithm', + ]) { + expect(RETIRED_DEFS_BY_MAJOR[18], `${def} must be declared`).toContain(def); + } + // The absorbed rename's tombstone row stays: its def left whole, which is + // the steady state gate (b3) exempts — deleting the row would erase the + // record that `monitoringWindow` was ever retired. + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain('integration/CircuitBreakerConfig:monitoringWindow'); + }); + + it('wires the D2 conversion into the step-18 chain, AFTER the duration rename it absorbs', () => { + const ids = MIGRATIONS_BY_MAJOR[18]!.conversionIds; + expect(ids).toContain('connector-resilience-keys-removed'); + expect(ids.indexOf('connector-resilience-keys-removed')) + .toBeGreaterThan(ids.indexOf('connector-health-and-trigger-durations-unit-in-key')); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion and the chain', () => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((s) => s.id === 'connector-resilience-keys-retired'); + expect(entry, 'the family needs its own D3 entry (ruling B)').toBeDefined(); + expect(entry!.reason).toContain('`connector-resilience-keys-removed`'); + expect(entry!.reason).toContain('`connector-health-and-trigger-durations-unit-in-key`'); + expect(entry!.reason).toContain('`health.circuitBreaker.monitoringWindow`'); + expect(entry!.replacement).toContain('top-level `webhooks:` collection'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +describe('connector resilience family retirement — the seven defs leave every public entry', () => { + /** The names the seven retired defs exported (7 schema consts + their types). */ + const RETIRED_NAMES = [ + 'ConnectorHealthSchema', 'ConnectorHealth', 'ConnectorHealthParsed', + 'HealthCheckConfigSchema', 'HealthCheckConfig', 'HealthCheckConfigParsed', + 'CircuitBreakerConfigSchema', 'CircuitBreakerConfig', 'CircuitBreakerConfigParsed', + 'ConnectorStatusSchema', 'ConnectorStatus', + 'WebhookConfigSchema', 'WebhookConfig', 'WebhookConfigParsed', + 'WebhookEventSchema', 'WebhookEvent', + 'WebhookSignatureAlgorithmSchema', 'WebhookSignatureAlgorithm', + ] as const; + + it('every retired name has ZERO holders on any public entry; the carriers survive', () => { + // Anti-vacuity: the baseline must cover the real surface. + expect(EXPORT_ENTRY_POINTS).toContain('./integration'); + expect(exportNamesOf('./integration').length).toBeGreaterThan(20); + for (const name of RETIRED_NAMES) { + expect(holdersOf(name), `${name} must have zero holders`).toEqual([]); + } + const integrationNames = exportNamesOf('./integration'); + for (const name of ['ConnectorSchema', 'DeclarativeConnectorEntrySchema', 'RetryConfigSchema', 'ConnectorTriggerSchema']) { + expect(integrationNames, `${name} must SURVIVE this retirement`).toContain(name); + } + }); + + it('the integration barrel resolves without the retired schemas', async () => { + const integration = await import('./index'); + for (const name of RETIRED_NAMES.filter((n) => n.endsWith('Schema'))) { + expect(integration).not.toHaveProperty(name); + } + expect(integration).toHaveProperty('ConnectorSchema'); + }); +}); + +// ─── Tree-scoped absence, inside the radius already declared for this package ─ +// +// What this leg guarantees. `tsc` is the primary sweeper — every retired key +// is typed `never` and every retired export is gone, so a TypeScript authoring +// site fails to compile. The residue is everything `tsc` never compiles: JSON, +// YAML, MD, MDX and untyped `.js` / `.mjs` / `.cjs`. This walk covers that +// residue across the five repo roots `scripts/cross-package-test-inputs.mjs` +// already declares for `@objectstack/spec#test` (the #15513 radius, mirrored +// in `turbo.json`), so a resurrection inside it puts this suite into +// `turbo ls --affected`. The bound, stated: `docs/**`, `.claude/**`, +// `.github/**` and the repo-root files are outside the walk. +// +// The matcher judges AUTHORING SHAPES, never a mention. The three carrier keys +// (`health`, `status`, `webhooks`) are too common a spelling to judge by text — +// `status:` alone is authored thousands of times on other surfaces — so they +// are held by `tsc` and the parse refusal above. What IS distinctive is held +// here: the six breaker / probe leaf keys no other surface declares, in key +// position or read off an object; and the seven retired defs' exported names, +// imported from the spec package or used as a schema value. +describe('tree-scoped absence: nothing inside the declared radius still authors the family', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared (the #15513 bound). */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const LEAF_KEYS = 'halfOpenMaxRequests|fallbackStrategy|unhealthyThreshold|healthyThreshold|monitoringWindowMs|monitoringWindow'; + const NAMES = 'ConnectorHealth|HealthCheckConfig|CircuitBreakerConfig|ConnectorStatus|WebhookConfig|WebhookEvent|WebhookSignatureAlgorithm'; + const AUTHORING = [ + // A breaker / probe leaf key in key position (TS / JSON / YAML) or read off an object. + new RegExp(`(^|[^\\w.])(${LEAF_KEYS})["']?\\s*:|\\.(${LEAF_KEYS})\\b`, 'm'), + // A retired name imported from the spec package. + new RegExp(`import\\s+(type\\s+)?\\{[^}]*\\b(${NAMES})(Schema|Parsed)?\\b[^}]*\\}\\s*from\\s*['"]@objectstack/spec`, 'm'), + // A retired schema used as a value. + new RegExp(`\\b(${NAMES})Schema\\s*\\.\\s*(parse|safeParse|parseAsync|safeParseAsync|shape|extend|options)\\b|typeof\\s+(${NAMES})Schema\\b`, 'm'), + ]; + + /** + * Prose mentions are spelled in INLINE CODE throughout this repo — the house + * style `check:doc-authoring` enforces — so stripping single-backtick spans + * separates "the retirement kit describing what it removed" from "a source + * still writing it". Newline-bounded: a fenced block's content is NOT + * stripped, so an authoring inside a fenced example is still caught. + */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => { + const stripped = stripInlineCode(text); + for (const re of AUTHORING) { + const m = re.exec(stripped); + if (m) return m; + } + return null; + }; + + /** + * Structural exclusions — the retirement kit and its projections, each with + * its reason. ⛔ NOT an allowlist file (`spec-property-retirement` §4). + */ + const EXCLUDED = new Set([ + // The declaring file: tombstones and the removal record. + 'packages/spec/src/integration/connector.zod.ts', + // The ledger rows `retiredKey()` keeps in the walked shape. + 'packages/spec/liveness/connector.json', + // The pre-release authorable baseline: written ONLY by `gen:authorable-surface-base`, + // never hand-edited or reverted — it is the record the removal is judged against. + 'packages/spec/authorable-surface.base.json', + // This pin names the family to assert its absence. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion, its fixture and the strip target. + 'packages/spec/src/conversions/', + // Registers the retirement by key and def (entries + the generated registry). + 'packages/spec/src/migrations/', + // Generated projections of the registry. + 'packages/spec/spec-changes.json', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + '.changeset/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `connector.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build (#15513's measured ENOENT). */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention (anti-vacuity)', () => { + expect(judge(' circuitBreaker: { enabled: true, halfOpenMaxRequests: 2 },')).not.toBeNull(); + expect(judge(' "fallbackStrategy": "cache",')).not.toBeNull(); + expect(judge('monitoringWindow: 120000 # yaml')).not.toBeNull(); + expect(judge('const w = def.health.circuitBreaker.monitoringWindowMs;')).not.toBeNull(); + expect(judge("import { ConnectorStatusSchema } from '@objectstack/spec/integration';")).not.toBeNull(); + expect(judge("import type {\n Connector,\n WebhookConfig,\n} from '@objectstack/spec';")).not.toBeNull(); + expect(judge('HealthCheckConfigSchema.parse({ enabled: true })')).not.toBeNull(); + expect(judge('type C = z.infer;')).not.toBeNull(); + // ⛔ NARROWNESS of the strip: a real authoring sharing a line with inline code still counts. + expect(judge('// see `retryConfig` — unhealthyThreshold: 3,')).not.toBeNull(); + // Prose: the retirement kit must be able to describe what it removed. + expect(judge('`circuitBreaker.monitoringWindowMs` and the `monitoringWindow` spelling')).toBeNull(); + expect(judge('the whole shape leaves with it (`ConnectorHealth`, `HealthCheckConfig`)')).toBeNull(); + expect(judge('"integration/CircuitBreakerConfig:halfOpenMaxRequests",')).toBeNull(); + // Neighbours that merely share a word: the cache breaker, the live kernel + // contract and the delivered webhook shape stay legal. + expect(judge('circuitBreaker: { enabled: true, failureThreshold: 5, resetTimeoutSeconds: 30 },')).toBeNull(); + expect(judge('healthCheck: { intervalMs: 30000, failureThreshold: 3 }')).toBeNull(); + expect(judge("import { EventWebhookConfigSchema, WebhookSchema } from '@objectstack/spec/kernel';")).toBeNull(); + expect(judge(' defaultFallbackStrategy: 1,')).toBeNull(); + }); + + it('no authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim()}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree. + expect(visited).toBeGreaterThan(1000); + expect(offenders, 'an authoring of the retired family means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 4ac2948ca4d..ff2d4d60b0e 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -9,26 +9,26 @@ import { SyncStrategySchema, ConnectorConflictResolutionSchema, - // Webhook - WebhookConfigSchema, - WebhookEventSchema, - + // (The connector-nested webhook shape — `WebhookConfigSchema` / + // `WebhookEventSchema` — was retired with `connector.webhooks`, ADR-0049; + // `connector-resilience-keys-retirement.test.ts` pins its absence.) + // Retry (rate limiting retired in #4911 — see the pin block at the bottom) RetryConfigSchema, // Base Connector ConnectorSchema, ConnectorTypeSchema, - ConnectorStatusSchema, // Action + its declared upstream effect (#4395) ConnectorActionSchema, ConnectorActionEffectSchema, - // Health & Circuit Breaker - HealthCheckConfigSchema, - CircuitBreakerConfigSchema, - ConnectorHealthSchema, + // (Health & circuit breaker — `HealthCheckConfigSchema`, + // `CircuitBreakerConfigSchema`, `ConnectorHealthSchema` — and + // `ConnectorStatusSchema` were retired with `connector.health` / + // `connector.status`, ADR-0049; pinned in + // `connector-resilience-keys-retirement.test.ts`.) // Trigger (declared-but-unread, #3197 — the pin block at the bottom judges // its unit-carrying key name, not a runtime it does not have) @@ -38,7 +38,6 @@ import { type Connector, type ConnectorFieldMapping, type DataSyncConfig, - type WebhookConfig, // The `/meta/connector/:name` door's schema (#6245) — `ConnectorSchema` plus // the ADR-0097 cross-field rules. The envelope pins below drive BOTH, because @@ -278,56 +277,13 @@ describe('DataSyncConfigSchema', () => { }); // ============================================================================ -// Webhook Configuration Tests +// Webhook Configuration Tests — RETIRED // ============================================================================ - -describe('WebhookConfigSchema', () => { - it('should accept valid webhook configuration', () => { - const webhook: WebhookConfig = { - name: 'test_webhook', - url: 'https://api.example.com/webhooks', - events: ['record.created', 'record.updated'], - secret: 'webhook-secret', - signatureAlgorithm: 'hmac_sha256', - }; - - expect(() => WebhookConfigSchema.parse(webhook)).not.toThrow(); - }); - - it('should use default values', () => { - const webhook = { - name: 'default_webhook', - url: 'https://api.example.com/webhooks', - events: ['record.created'], - }; - - const parsed = WebhookConfigSchema.parse(webhook); - expect(parsed.signatureAlgorithm).toBe('hmac_sha256'); - expect(parsed.timeoutMs).toBe(30000); - }); - - // #4001 batch 11 closed the BASE (`automation/webhook.zod.ts`), and zod - // carries both the strictness and the base's error map through `.extend()`. - // That is the trap the ledger records as finding 16 — a base tightened for - // one surface silently retightening another — so it is asserted here, on the - // extension's own file, rather than left for someone to discover. - it('inherits the base webhook\'s strictness through `.extend()` (#4001)', () => { - const result = WebhookConfigSchema.safeParse({ - name: 'test_webhook', url: 'https://api.example.com/webhooks', notAKey: 1, - }); - expect(result.success).toBe(false); - expect(result.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); - }); - - it('still accepts the two keys the extension adds', () => { - // The base names `signatureAlgorithm` in `extraKeys` so a typo of it is - // still suggestible on this surface, where the base has never heard of it. - expect(WebhookConfigSchema.safeParse({ - name: 'test_webhook', url: 'https://api.example.com/webhooks', - events: ['record.created'], signatureAlgorithm: 'hmac_sha512', - }).success).toBe(true); - }); -}); +// (`WebhookConfigSchema` tests lived here until the connector-nested `webhooks` +// shape was retired under ADR-0049 — a webhook nested in a connector was never +// registered or delivered. The retirement is pinned in +// `connector-resilience-keys-retirement.test.ts`; the delivered webhook shape is +// `automation/webhook.zod.ts`, tested beside it.) // ============================================================================ // Retry Tests @@ -432,7 +388,6 @@ describe('ConnectorSchema', () => { type: 'api-key', key: 'test-key', }, - status: 'inactive', enabled: true, }; @@ -479,18 +434,12 @@ describe('ConnectorSchema', () => { target: 'external_id', }, ], - webhooks: [ - { - name: 'connector_webhook', - url: 'https://api.example.com/webhook', - events: ['record.created'], - }, - ], + // `webhooks` and `status` were authored here until ADR-0049 retired them + // with `health` (`connector-resilience-keys-retirement.test.ts`). // `rateLimitConfig` was authored here until #4911 retired it. retryConfig: { maxAttempts: 3, }, - status: 'active', enabled: true, metadata: { version: '1.0', @@ -500,144 +449,17 @@ describe('ConnectorSchema', () => { const parsed = ConnectorSchema.parse(connector); expect(parsed.description).toBe('A comprehensive connector'); expect(parsed.fieldMappings).toHaveLength(1); - expect(parsed.webhooks).toHaveLength(1); expect(parsed.metadata?.version).toBe('1.0'); }); }); // ============================================================================ -// Health Check Configuration Tests +// Health Check / Circuit Breaker / Connector Health Tests — RETIRED // ============================================================================ - -describe('HealthCheckConfigSchema', () => { - it('should accept minimal health check config', () => { - const config = HealthCheckConfigSchema.parse({ - enabled: true, - }); - - expect(config.enabled).toBe(true); - expect(config.intervalMs).toBe(60000); - expect(config.timeoutMs).toBe(5000); - expect(config.expectedStatus).toBe(200); - expect(config.unhealthyThreshold).toBe(3); - expect(config.healthyThreshold).toBe(1); - }); - - it('should accept full health check config', () => { - const config = HealthCheckConfigSchema.parse({ - enabled: true, - intervalMs: 30000, - timeoutMs: 10000, - endpoint: '/health', - method: 'HEAD', - expectedStatus: 204, - unhealthyThreshold: 5, - healthyThreshold: 2, - }); - - expect(config.endpoint).toBe('/health'); - expect(config.method).toBe('HEAD'); - expect(config.expectedStatus).toBe(204); - }); - - it('should accept all HTTP methods for health check', () => { - const methods = ['GET', 'HEAD', 'OPTIONS'] as const; - methods.forEach(method => { - const config = HealthCheckConfigSchema.parse({ enabled: true, method }); - expect(config.method).toBe(method); - }); - }); -}); - -// ============================================================================ -// Circuit Breaker Configuration Tests -// ============================================================================ - -describe('CircuitBreakerConfigSchema', () => { - it('should accept minimal circuit breaker config', () => { - const config = CircuitBreakerConfigSchema.parse({ - enabled: true, - }); - - expect(config.enabled).toBe(true); - expect(config.failureThreshold).toBe(5); - expect(config.resetTimeoutMs).toBe(30000); - expect(config.halfOpenMaxRequests).toBe(1); - expect(config.monitoringWindowMs).toBe(60000); - }); - - it('should accept full circuit breaker config', () => { - const config = CircuitBreakerConfigSchema.parse({ - enabled: true, - failureThreshold: 10, - resetTimeoutMs: 60000, - halfOpenMaxRequests: 3, - monitoringWindowMs: 120000, - fallbackStrategy: 'cache', - }); - - expect(config.failureThreshold).toBe(10); - expect(config.fallbackStrategy).toBe('cache'); - }); - - it('should accept all fallback strategies', () => { - const strategies = ['cache', 'default_value', 'error', 'queue'] as const; - strategies.forEach(strategy => { - const config = CircuitBreakerConfigSchema.parse({ - enabled: true, - fallbackStrategy: strategy, - }); - expect(config.fallbackStrategy).toBe(strategy); - }); - }); -}); - -// ============================================================================ -// Connector Health Configuration Tests -// ============================================================================ - -describe('ConnectorHealthSchema', () => { - it('should accept empty health config', () => { - const health = ConnectorHealthSchema.parse({}); - - expect(health.healthCheck).toBeUndefined(); - expect(health.circuitBreaker).toBeUndefined(); - }); - - it('should accept combined health check and circuit breaker', () => { - const health = ConnectorHealthSchema.parse({ - healthCheck: { - enabled: true, - intervalMs: 30000, - endpoint: '/ping', - }, - circuitBreaker: { - enabled: true, - failureThreshold: 3, - fallbackStrategy: 'queue', - }, - }); - - expect(health.healthCheck?.enabled).toBe(true); - expect(health.circuitBreaker?.fallbackStrategy).toBe('queue'); - }); - - it('should accept connector with health config', () => { - const connector = ConnectorSchema.parse({ - name: 'resilient_connector', - label: 'Resilient Connector', - type: 'api', - authentication: { type: 'none' }, - health: { - healthCheck: { enabled: true }, - circuitBreaker: { enabled: true, failureThreshold: 5 }, - }, - }); - - expect(connector.health?.healthCheck?.enabled).toBe(true); - expect(connector.health?.circuitBreaker?.failureThreshold).toBe(5); - }); -}); +// (`HealthCheckConfigSchema`, `CircuitBreakerConfigSchema` and +// `ConnectorHealthSchema` tests lived here until `connector.health` was retired +// under ADR-0049 — no connector probe or breaker ever ran. The retirement is +// pinned in `connector-resilience-keys-retirement.test.ts`.) // ─── [#4911] Outbound rate limiting retired — with the [#4684] pin folded in ── // @@ -1213,19 +1035,10 @@ describe('ADR-0010 protection envelope (#6362)', () => { expect(parsed._packageId).toBe('com.acme.billing'); }); - it('WebhookConfigSchema — the nested webhook — preserves it as well', () => { - // `WebhookConfigSchema` extends `WebhookSchema`, which has carried the - // spread since #4001 batch 11. Pinned here so the inherited behaviour - // cannot regress silently through a future `.extend()`/`.omit()` on the - // connector side. - const parsed = WebhookConfigSchema.parse({ - name: 'billing_events', - url: 'https://example.com/hooks/billing', - ...STAMPED_ENVELOPE, - }); - expect(parsed._packageId).toBe('com.acme.billing'); - expect(parsed._provenance).toBe('package'); - }); + // (A fifth case pinned the envelope through `WebhookConfigSchema`, the + // connector-nested webhook. That shape was retired with `connector.webhooks` + // under ADR-0049; the delivered `WebhookSchema` it extended keeps the spread + // and is pinned beside it in `automation/`.) }); // ─── [#14676] `connector.errorMapping` RETIRED, with the three defs it carried ── @@ -1471,7 +1284,8 @@ describe('[#14676] integration/ErrorMappingConfig + ErrorMappingRule + Connector for (const name of [ 'ConnectorSchema', 'DeclarativeConnectorEntrySchema', - 'ConnectorHealthSchema', + // (`ConnectorHealthSchema` stood here as a survivor until `connector.health` + // was itself retired, ADR-0049 — `connector-resilience-keys-retirement.test.ts`.) 'RetryConfigSchema', 'ConnectorFieldMappingSchema', ]) { @@ -1509,22 +1323,18 @@ describe('[#14676] ADR-0087 registration', () => { }); }); -// #15680 (stack card 5/6 of #14478) — ruling B. Both old spellings are -// `retiredKey()` tombstones; asserted on the issue CODE and the prescription, -// never on a bare `toThrow()`. Neither shape is strict, so without the -// tombstones the old keys would be STRIPPED in silence: a breaker would fall -// back to its 60-second default window while the author believed they had -// widened it, and a polling trigger would lose its cadence entirely. +// #15680 (stack card 5/6 of #14478) — ruling B. The old trigger spelling is a +// `retiredKey()` tombstone; asserted on the issue CODE and the prescription, +// never on a bare `toThrow()`. The shape is not strict, so without the +// tombstone the old key would be STRIPPED in silence and a polling trigger +// would lose its cadence entirely. +// +// This block also pinned the breaker half of the same card — +// `CircuitBreakerConfig.monitoringWindow` → `monitoringWindowMs` and the +// `resetTimeoutMs` neighbour. That half left with the whole `health` block +// (ADR-0049, `connector-resilience-keys-retirement.test.ts`), which pins that +// both breaker spellings now meet the `health` prescription. describe('connector durations carry their unit (#15680)', () => { - it('REFUSES the retired `monitoringWindow` with the rename in the message', () => { - const result = CircuitBreakerConfigSchema.safeParse({ enabled: true, monitoringWindow: 120000 }); - expect(result.success).toBe(false); - const issue = result.error!.issues.find((i) => i.path.join('.') === 'monitoringWindow'); - expect(issue).toBeDefined(); - expect(issue!.code).not.toBe('unrecognized_keys'); - expect(issue!.message).toContain('`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs`'); - }); - it('REFUSES the retired trigger `interval` with the rename in the message', () => { const result = ConnectorTriggerSchema.safeParse({ key: 'new_invoice', label: 'New invoice', type: 'polling', interval: 60, @@ -1536,15 +1346,9 @@ describe('connector durations carry their unit (#15680)', () => { expect(issue!.message).toContain('`ConnectorTrigger.interval` was renamed to `intervalSeconds`'); }); - it('accepts both new spellings and keeps the 60000 breaker default', () => { - expect(CircuitBreakerConfigSchema.parse({ enabled: true }).monitoringWindowMs).toBe(60000); - expect(CircuitBreakerConfigSchema.parse({ enabled: true, monitoringWindowMs: 120000 }).monitoringWindowMs).toBe(120000); + it('accepts the new trigger spelling', () => { expect(ConnectorTriggerSchema.parse({ key: 'new_invoice', label: 'New invoice', type: 'polling', intervalSeconds: 60, }).intervalSeconds).toBe(60); }); - - it('leaves `resetTimeoutMs` alone — it already carried its unit, and is the neighbour that made the bare `monitoringWindow` a collision', () => { - expect(CircuitBreakerConfigSchema.parse({ enabled: true }).resetTimeoutMs).toBe(30000); - }); }); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 151053d4560..704684dc6f0 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -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'; @@ -23,11 +22,13 @@ import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; * - **Enterprise Connector** (THIS FILE) - System integrators - Full SAP integration; connector-attached sync via `syncConfig` * * **SCOPE: Most comprehensive integration layer.** - * Includes authentication, webhooks, field mapping, bidirectional sync, - * retry policies, and complete lifecycle management. + * Includes authentication, field mapping, bidirectional sync, retry policies, + * and complete lifecycle management. * * This protocol supports multiple authentication strategies, bidirectional sync, - * field mapping, webhooks, and comprehensive retry and resilience policies. + * field mapping, and an executed retry policy. It declares no health probe, no + * circuit breaker, no authored status and no webhooks of its own — see "What + * this layer does NOT provide" below. * * ## What this layer does NOT provide * @@ -58,10 +59,18 @@ import { acceptRetiredDefaultResidue, 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. * - * ⛔ **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. + * **There is no connector health probe and no circuit breaker.** The + * `health` block (`healthCheck` and `circuitBreaker`) was removed in + * `@objectstack/spec` 17 (ADR-0049 enforce-or-remove) together with the + * authored `status` and the nested `webhooks` array: nothing ever scheduled a + * probe, opened a breaker, read an authored status or delivered a webhook + * declared inside a connector. Implement probes and circuit breaking in the + * connector provider or an upstream gateway; whether a registered connector can + * be dispatched is the computed `state` (`ready` / `degraded`) that + * `GET /api/v1/automation/connectors` reports; and a webhook that is actually + * delivered is declared in the stack's top-level `webhooks:` collection. The + * "REMOVED: `health`, `status` and the nested `webhooks`" section below records + * the measurement. * * `connectionTimeoutMs` used to be the second exception and is now **removed** * (ADR-0049, the narrower second decision that surface was owed): it was @@ -116,12 +125,11 @@ import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; * - Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) * - Complex OAuth2/SAML authentication required * - Bidirectional sync with field mapping (`dataType` / `syncMode` per field — it moves values, it does not transform them) - * - Webhook management required * - Full CRUD operations and data synchronization * - Need comprehensive retry strategies and error handling - * + * * **Examples:** - * - Full Salesforce integration with webhooks + * - Full Salesforce integration * - SAP ERP connector with CDC (Change Data Capture) * - Microsoft Dynamics 365 connector * @@ -329,63 +337,24 @@ export type DataSyncConfig = z.input; export type DataSyncConfigParsed = z.infer; // ============================================================================ -// Webhook Configuration +// REMOVED: the connector-nested webhook shape (ADR-0049 enforce-or-remove) // ============================================================================ - -/** - * Webhook Event Schema - */ -export const WebhookEventSchema = lazySchema(() => z.enum([ - 'record.created', - 'record.updated', - 'record.deleted', - 'sync.started', - 'sync.completed', - 'sync.failed', - 'auth.expired', - 'rate_limit.exceeded', -]).describe('Webhook event type')); - -export type WebhookEvent = z.input; - -/** - * Webhook Signature Algorithm - */ -export const WebhookSignatureAlgorithmSchema = lazySchema(() => z.enum([ - 'hmac_sha256', - 'hmac_sha512', - 'none', -]).describe('Webhook signature algorithm')); - -export type WebhookSignatureAlgorithm = z.input; - -/** - * Webhook Configuration Schema - * - * Extends the canonical WebhookSchema with connector-specific event types. - * This allows connectors to subscribe to both data events and connector lifecycle events. - * - * ⚠️ NOT YET ENFORCED — declared but ignored at registration (#3197). - * `AutomationEngine.registerConnector` reads only `actions`; a connector's - * `webhooks` (including `events`) parse and are stored, but no runtime - * dispatches, emits, or filters on them. - */ -export const WebhookConfigSchema = lazySchema(() => WebhookSchema.extend({ - /** - * Events to listen for - * Connector-specific events like sync completion, auth expiry, etc. - */ - events: z.array(WebhookEventSchema).optional().describe('Connector events to subscribe to '), - - /** - * Signature algorithm for webhook security - */ - signatureAlgorithm: WebhookSignatureAlgorithmSchema.optional().default('hmac_sha256'), -})); - -export type WebhookConfig = z.input; -/** Post-parse shape of {@link WebhookConfig} — defaults applied, transforms run (ADR-0122). */ -export type WebhookConfigParsed = z.infer; +// +// `WebhookConfigSchema` (the canonical `WebhookSchema` `.extend()`ed with +// `events` and `signatureAlgorithm`), its `WebhookEventSchema` event vocabulary +// (`record.*`, `sync.*`, `auth.expired`, `rate_limit.exceeded`) and the +// `WebhookSignatureAlgorithmSchema` enum used to live here, authorable only +// through `ConnectorSchema.webhooks`. They left whole with that key: a webhook +// nested inside a connector was never registered as a `webhook` metadata item +// (the stack decomposition registers a connector entry WHOLE), so +// `@objectstack/plugin-webhooks` never materialized it into `sys_webhook` and +// nothing ever delivered it — and no code path emits a connector lifecycle +// event (`sync.completed`, `auth.expired`, …) for `events` to have subscribed +// to. The three defs are declared in `RETIRED_DEFS_BY_MAJOR[18]`; the carrier +// key is the `webhooks` tombstone on `ConnectorBaseSchema` below, and the +// section "REMOVED: `health`, `status` and the nested `webhooks`" records the +// measurement. A webhook that is actually delivered is declared in the stack's +// top-level `webhooks:` collection (`automation/webhook.zod.ts`). // ============================================================================ // Retry Configuration @@ -646,79 +615,134 @@ const CONNECTION_TIMEOUT_MS_RETIRED = * `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. + * + * ⭐ `status: 'inactive'` joined the stage when `status` was retired, for the + * same measured reason and by the same class rule: the key was declared + * `.optional().default('inactive')`, so the same 17.x parse above emitted + * `status: 'inactive'` into every connector too (it is in that emitted key + * list), and a def built by a released toolchain carries it back to + * `registerConnector`. `'active'`, `'error'` and `'configuring'` were never + * materialized by anything but an author (or a plugin's own literal), so they + * keep the tombstone's refusal with the prescription. */ const CONNECTOR_RETIRED_KEY_RESIDUE = { connectionTimeoutMs: 30000, + status: 'inactive', } as const; // ============================================================================ -// Health Check & Circuit Breaker Configuration +// REMOVED: `health`, `status` and the nested `webhooks` (ADR-0049) // ============================================================================ +// +// Sixteen authorable keys on this schema, in three families, that NOTHING read — +// retired together under ADR-0049 enforce-or-remove, by the maintainer's +// criterion for a declared-but-unenforced family: does the mainstream platform +// offer this capability? Yes ⇒ build the consumer once, correctly; no ⇒ retire. +// Measured on `origin/main` before the removal, with a lit control beside each +// zero: +// +// - `health.healthCheck` (`enabled`, `intervalMs`, `timeoutMs`, `endpoint`, +// `method`, `expectedStatus`, `unhealthyThreshold`, `healthyThreshold`) and +// `health.circuitBreaker` (`enabled`, `failureThreshold`, `resetTimeoutMs`, +// `halfOpenMaxRequests`, `monitoringWindowMs`, `fallbackStrategy`): zero +// reads outside `packages/spec`. No loop ever polled a connector endpoint, +// counted consecutive failures or crossed a threshold, and no state machine +// ever opened, half-opened or closed a breaker; `fallbackStrategy` named four +// behaviours none of which was implemented. The only `healthCheck` code +// outside this package is the KERNEL's plugin health contract — a different +// shape on a different subject. (Control: `retryConfig`, the executed +// sibling policy, is read in the same scope.) Mainstream connector metadata +// (Salesforce Named Credentials, Power Platform custom connectors, +// Retool / Appsmith resources) carries no author-configured probe or +// breaker; breakers live in API-gateway infrastructure. +// - `status` (`active` / `inactive` / `error` / `configuring`, defaulted +// `'inactive'`): zero reads. `GET /api/v1/automation/connectors` publishes +// `state` (`ready` / `degraded`), which the runtime COMPUTES and no authored +// value can set — two names one letter apart on one payload, only one of +// them real. What decides participation is `enabled` (and `provider` on a +// declarative instance). The four shipped connector packages and the +// automation service's degraded husk WROTE the key (`'active'` / `'error'`) +// and nothing read it back; those writes were deleted with it. +// - `webhooks` (the nested `WebhookConfig[]`): zero reads of a connector's own +// array. The stack decomposition registers a connector entry WHOLE, so a +// webhook nested in it never became a `webhook` item, never reached +// `@objectstack/plugin-webhooks`' materializer and was never delivered — the +// top-level `webhooks:` collection is the delivered one. +// +// `ConnectorSchema` is NOT `.strict()`, so a plain delete would be a silent +// strip (ADR-0104): each carrier key is a `retiredKey()` tombstone below, +// inherited by `DeclarativeConnectorEntrySchema` because both published +// carriers wrap the same private `ConnectorBaseSchema`. The shapes behind them +// leave whole — `integration/ConnectorHealth`, `integration/HealthCheckConfig`, +// `integration/CircuitBreakerConfig`, `integration/ConnectorStatus`, +// `integration/WebhookConfig`, `integration/WebhookEvent` and +// `integration/WebhookSignatureAlgorithm` in `RETIRED_DEFS_BY_MAJOR[18]` — +// because an exported value schema with no consumer reads as a capability. +// Registered as `integration/Connector:{health,status,webhooks}` and +// `integration/DeclarativeConnectorEntry:{health,status,webhooks}` in +// `RETIRED_KEYS_BY_MAJOR[18]`; authored sources and stored rows are rewritten +// by the D2 conversion `connector-resilience-keys-removed`, and the family's +// judgement lives in the D3 entry `connector-resilience-keys-retired`. +// +// `circuitBreaker.monitoringWindow` → `monitoringWindowMs` was renamed earlier +// in this same unreleased protocol step; the rename's breaker half is ABSORBED +// by this removal (`spec-property-retirement` §0): a renamed key that is then +// stripped with its whole block is unobservable, and the conversion table's +// disjoint-fixture contract cannot hold both. `triggers[].interval` → +// `intervalSeconds` is a different family and is untouched. /** - * Health Check Configuration - * - * Configures periodic health checks for connector endpoints. + * The prescription an author meets when they write `health` — 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, including the author + * who still holds the pre-rename `monitoringWindow` spelling; the closing + * sentence is the house `os migrate meta` form pinned by + * `shared/retired-key-migrate-sentence.test.ts`. */ -export const HealthCheckConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().describe('Enable health checks'), - intervalMs: z.number().optional().default(60000).describe('Health check interval in milliseconds'), - timeoutMs: z.number().optional().default(5000).describe('Health check timeout in milliseconds'), - endpoint: z.string().optional().describe('Health check endpoint path'), - method: z.enum(['GET', 'HEAD', 'OPTIONS']).optional().describe('HTTP method for health check'), - expectedStatus: z.number().optional().default(200).describe('Expected HTTP status code'), - unhealthyThreshold: z.number().optional().default(3).describe('Consecutive failures before marking unhealthy'), - healthyThreshold: z.number().optional().default(1).describe('Consecutive successes before marking healthy'), -}).describe('Health check configuration')); - -export type HealthCheckConfig = z.input; -/** Post-parse shape of {@link HealthCheckConfig} — defaults applied, transforms run (ADR-0122). */ -export type HealthCheckConfigParsed = z.infer; +const HEALTH_RETIRED = + '`connector.health` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'no connector health probe or circuit breaker ever existed: nothing scheduled a ' + + '`healthCheck` request, counted consecutive failures against a threshold, or opened, ' + + 'half-opened or closed a `circuitBreaker`, and no `fallbackStrategy` was ever applied, so ' + + 'every key in the block configured nothing. That includes `circuitBreaker.monitoringWindowMs` ' + + 'and the `monitoringWindow` spelling it was renamed from: the renamed key is removed with the ' + + 'rest. Delete the key; the whole shape leaves with it (`ConnectorHealth`, ' + + '`HealthCheckConfig`, `CircuitBreakerConfig`). Whether a connector can be dispatched is ' + + 'computed, not authored: `GET /api/v1/automation/connectors` reports each connector\'s ' + + '`state` (`ready` or `degraded`). Put health probes and circuit breaking in the connector ' + + 'provider or an upstream gateway. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; /** - * Circuit Breaker Configuration - * - * Implements the circuit breaker pattern to prevent cascading failures. + * The prescription an author meets when they write `status` — in `tsc` and at + * parse. Only a NON-default value reaches it at parse: the emitted default + * `'inactive'` is accepted and stripped as inert residue by + * {@link CONNECTOR_RETIRED_KEY_RESIDUE}. */ -export const CircuitBreakerConfigSchema = lazySchema(() => z.object({ - enabled: z.boolean().describe('Enable circuit breaker'), - failureThreshold: z.number().optional().default(5).describe('Failures before opening circuit'), - resetTimeoutMs: z.number().optional().default(30000).describe('Time in open state before half-open'), - halfOpenMaxRequests: z.number().optional().default(1).describe('Requests allowed in half-open state'), - // Renamed from `monitoringWindow` (#15680, ruling B on #14478): the unit lived - // only in the describe prose, one key below `resetTimeoutMs`, which already - // spelled ITS unit. One shape carrying both conventions — the suffixed one was - // the honest half. - monitoringWindowMs: z.number().optional().default(60000).describe('Rolling window for failure count in ms'), - - /** Tombstone for the rename above (#15680, ruling B on #14478). */ - monitoringWindow: retiredKey( - '`CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in ' - + '@objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not ' - + 'only in the describe prose. Rename the key to `monitoringWindowMs`; the value ' - + '(milliseconds) and the 60000 default are unchanged. ' - + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', - ), - fallbackStrategy: z.enum(['cache', 'default_value', 'error', 'queue']).optional().describe('Fallback strategy when circuit is open'), -}).describe('Circuit breaker configuration')); - -export type CircuitBreakerConfig = z.input; -/** Post-parse shape of {@link CircuitBreakerConfig} — defaults applied, transforms run (ADR-0122). */ -export type CircuitBreakerConfigParsed = z.infer; +const STATUS_RETIRED = + '`connector.status` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + "nothing ever read it: `status: 'active'` neither enabled nor advertised a connector, and " + + "`'error'` or `'configuring'` changed nothing either. Delete the key; the " + + '`ConnectorStatus` enum leaves with it. On a declarative entry, `enabled: false` is what ' + + 'withdraws a materialized instance or marks a catalog-only descriptor, and whether a ' + + 'registered connector can be dispatched is computed by the runtime and reported as `state` ' + + '(`ready` or `degraded`) on `GET /api/v1/automation/connectors` — no authored value sets it. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; /** - * Connector Health Configuration - * - * Combines health check and circuit breaker for connector resilience. + * The prescription an author meets when they write `webhooks` on a connector — + * in `tsc` and at parse. */ -export const ConnectorHealthSchema = lazySchema(() => z.object({ - healthCheck: HealthCheckConfigSchema.optional().describe('Health check configuration'), - circuitBreaker: CircuitBreakerConfigSchema.optional().describe('Circuit breaker configuration'), -}).describe('Connector health configuration')); - -export type ConnectorHealth = z.input; -/** Post-parse shape of {@link ConnectorHealth} — defaults applied, transforms run (ADR-0122). */ -export type ConnectorHealthParsed = z.infer; +const WEBHOOKS_RETIRED = + '`connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — ' + + 'a webhook nested inside a connector was never registered as a `webhook` item, so it was ' + + 'never materialized into `sys_webhook` and never delivered, and nothing emits the connector ' + + 'events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete ' + + 'the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, ' + + '`WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack\'s ' + + 'top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on ' + + 'record events — note that doing so STARTS deliveries this connector never made. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; // ============================================================================ // Base Connector Schema @@ -738,17 +762,11 @@ export const ConnectorTypeSchema = lazySchema(() => z.enum([ export type ConnectorType = z.input; -/** - * Connector Status - */ -export const ConnectorStatusSchema = lazySchema(() => z.enum([ - 'active', // Connector is active and syncing - 'inactive', // Connector is configured but disabled - 'error', // Connector has errors - 'configuring', // Connector is being set up -]).describe('Connector status')); - -export type ConnectorStatus = z.input; +// `ConnectorStatusSchema` / `ConnectorStatus` (`active` / `inactive` / `error` / +// `configuring`) used to be declared here. It left whole with the `status` key +// it was the only carrier of — see "REMOVED: `health`, `status` and the nested +// `webhooks`" above; the runtime's dispatchability answer is the computed +// `ConnectorState` (`ready` / `degraded`) in `connector-descriptor.ts`. /** * What one connector action does **upstream** (#4395). @@ -952,9 +970,15 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ fieldMappings: z.array(ConnectorFieldMappingSchema).optional().describe('Field mapping rules'), /** - * Webhook configuration + * `webhooks` — RETIRED (ADR-0049 enforce-or-remove). A webhook nested in a + * connector was never registered as a `webhook` item, so it was never + * materialized into `sys_webhook` and never delivered; the delivered surface + * is the stack's top-level `webhooks:` collection. `ConnectorSchema` is NOT + * `.strict()`, so a plain delete would be a silent strip (ADR-0104); the + * tombstone makes the removal audible in `tsc` and at parse. See "REMOVED: + * `health`, `status` and the nested `webhooks`" above. */ - webhooks: z.array(WebhookConfigSchema).optional().describe('Webhook configurations '), + webhooks: retiredKey(WEBHOOKS_RETIRED), /** * REMOVED (#4911) — outbound rate limiting. See the block above @@ -997,9 +1021,16 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ requestTimeoutMs: z.number().min(1000).max(300000).optional().default(30000).describe('Request timeout in ms'), /** - * Connector status + * `status` — RETIRED (ADR-0049 enforce-or-remove). Declared with a + * `.default('inactive')` and read by nothing: the runtime's dispatchability + * answer is the COMPUTED `state` (`ready` / `degraded`) that + * `GET /api/v1/automation/connectors` publishes, and participation is + * `enabled` below. Tombstoned rather than deleted (non-strict schema, + * ADR-0104); its emitted default `'inactive'` is accepted and stripped as + * inert residue by {@link CONNECTOR_RETIRED_KEY_RESIDUE}, every other value + * meets the prescription. */ - status: ConnectorStatusSchema.optional().default('inactive').describe('Connector status'), + status: retiredKey(STATUS_RETIRED), /** * Enable connector. On a declarative `connectors:` stack entry, `false` @@ -1032,9 +1063,13 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ errorMapping: retiredKey(ERROR_MAPPING_RETIRED), /** - * Health check and circuit breaker configuration + * `health` — RETIRED (ADR-0049 enforce-or-remove). The `healthCheck` probe + * and the `circuitBreaker` blocks (fourteen keys) had no engine: nothing + * polled, counted or tripped. Tombstoned rather than deleted (non-strict + * schema, ADR-0104); the three shapes behind it left whole. See "REMOVED: + * `health`, `status` and the nested `webhooks`" above. */ - health: ConnectorHealthSchema.optional().describe('Health and resilience configuration'), + health: retiredKey(HEALTH_RETIRED), /** * Custom metadata @@ -1080,9 +1115,10 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ * 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. + * (`RetryConfigSchema`, which keeps all nine — the control was + * `WebhookConfigSchema` until that shape was retired with the nested + * `webhooks`): `.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 @@ -1121,7 +1157,8 @@ export function defineConnector(config: z.input): Connec * 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()`, + * control (`RetryConfigSchema`; `WebhookConfigSchema` until its retirement) + * 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 diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__CircuitBreakerConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__CircuitBreakerConfig.ts new file mode 100644 index 00000000000..189fba4b0ae --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__CircuitBreakerConfig.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/CircuitBreakerConfig` (`enabled`, `failureThreshold`, +// `resetTimeoutMs`, `halfOpenMaxRequests`, `monitoringWindowMs`, +// `fallbackStrategy`, and the `monitoringWindow` rename tombstone) leaves with +// `integration/ConnectorHealth`, whose `circuitBreaker` was its only carrier. No +// state machine ever opened, half-opened or closed a breaker, and none of the +// four `fallbackStrategy` behaviours was implemented. Its `monitoringWindow` +// tombstone leaves with it: the `RETIRED_KEYS_BY_MAJOR[18]` row +// `integration/CircuitBreakerConfig:monitoringWindow` stays, which is the +// whole-def removal steady state gate (b3) of `scripts/build-schemas.ts` +// deliberately exempts. See `retired-keys/18.integration__Connector__health.ts` +// for the retirement record. +export const entry = 'integration/CircuitBreakerConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorHealth.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorHealth.ts new file mode 100644 index 00000000000..fc04f4f9fbf --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorHealth.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/ConnectorHealth` (`healthCheck`, `circuitBreaker`) leaves with its +// only carrier, `ConnectorSchema.health`, tombstoned in this same major under +// ADR-0049 enforce-or-remove (`RETIRED_KEYS_BY_MAJOR[18]`). Nothing outside the +// declaring file ever parsed or constructed one, and an exported value schema +// with no consumer reads as a capability (#3950). See +// `retired-keys/18.integration__Connector__health.ts` for the retirement record. +export const entry = 'integration/ConnectorHealth'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorStatus.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorStatus.ts new file mode 100644 index 00000000000..8b20a0a4562 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__ConnectorStatus.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/ConnectorStatus` (`active` / `inactive` / `error` / +// `configuring`) leaves with its only carrier, `ConnectorSchema.status`, +// tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a +// connector's `status`; the runtime's dispatchability answer is the computed +// `ConnectorState` (`ready` / `degraded`, `integration/connector-descriptor.ts`), +// which is a TypeScript type and not a published def, so nothing replaces this +// one. See `retired-keys/18.integration__Connector__status.ts` for the +// retirement record. +export const entry = 'integration/ConnectorStatus'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__HealthCheckConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__HealthCheckConfig.ts new file mode 100644 index 00000000000..969c1185f84 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__HealthCheckConfig.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/HealthCheckConfig` (`enabled`, `intervalMs`, `timeoutMs`, +// `endpoint`, `method`, `expectedStatus`, `unhealthyThreshold`, +// `healthyThreshold`) leaves with `integration/ConnectorHealth`, whose +// `healthCheck` was its only carrier. No loop ever polled a connector endpoint: +// the only `healthCheck` code outside `packages/spec` is the kernel's PLUGIN +// health contract — a different shape on a different subject. Its four +// `.default()`s were only ever materialized INSIDE an authored block, so there +// is no residue window on the carrier. See +// `retired-keys/18.integration__Connector__health.ts` for the retirement record. +export const entry = 'integration/HealthCheckConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookConfig.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookConfig.ts new file mode 100644 index 00000000000..f0139df0778 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookConfig.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/WebhookConfig` — the canonical `webhook` shape `.extend()`ed with +// `events` and `signatureAlgorithm` — leaves with its only carrier, +// `ConnectorSchema.webhooks`, tombstoned in this same major under ADR-0049 +// enforce-or-remove. A webhook nested in a connector was never registered, +// materialized or delivered; the delivered shape is `automation/Webhook`, which +// is unaffected. See `retired-keys/18.integration__Connector__webhooks.ts` for +// the retirement record. +export const entry = 'integration/WebhookConfig'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookEvent.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookEvent.ts new file mode 100644 index 00000000000..c467c8d3ccb --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookEvent.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/WebhookEvent` (`record.created` / `record.updated` / +// `record.deleted` / `sync.started` / `sync.completed` / `sync.failed` / +// `auth.expired` / `rate_limit.exceeded`) leaves with `integration/WebhookConfig`, +// whose `events` was its only carrier. No code path emits any of the connector +// lifecycle events it names. See +// `retired-keys/18.integration__Connector__webhooks.ts` for the retirement record. +export const entry = 'integration/WebhookEvent'; diff --git a/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookSignatureAlgorithm.ts b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookSignatureAlgorithm.ts new file mode 100644 index 00000000000..f0600203426 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.integration__WebhookSignatureAlgorithm.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// `integration/WebhookSignatureAlgorithm` (`hmac_sha256` / `hmac_sha512` / +// `none`) leaves with `integration/WebhookConfig`, whose `signatureAlgorithm` was +// its only carrier. A delivered webhook (the top-level `webhooks:` collection) is +// signed by the messaging outbox from its `secret`; nothing ever read this +// choice. See `retired-keys/18.integration__Connector__webhooks.ts` for the +// retirement record. +export const entry = 'integration/WebhookSignatureAlgorithm'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__health.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__health.ts new file mode 100644 index 00000000000..8d00cc49366 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__health.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `ConnectorSchema.health` — the connector +// resilience family (one batch with `status` and the nested `webhooks`), retired +// by the maintainer's criterion for a declared-but-unenforced family: does the +// mainstream platform offer the capability? Author-configured health probes and +// circuit breakers are not connector metadata anywhere in the mainstream +// (Salesforce Named Credentials, Power Platform custom connectors, Retool / +// Appsmith resources); breakers live in API-gateway infrastructure. +// +// Measured on `origin/main` before the removal: the fourteen keys under the block +// — `healthCheck.{enabled, intervalMs, timeoutMs, endpoint, method, +// expectedStatus, unhealthyThreshold, healthyThreshold}` and +// `circuitBreaker.{enabled, failureThreshold, resetTimeoutMs, +// halfOpenMaxRequests, monitoringWindowMs, fallbackStrategy}` — have ZERO reads +// outside `packages/spec` (lit control in the same scan: `retryConfig`, the +// executed sibling policy, read 17 times in `packages/connectors` and +// `packages/services/service-automation`). Nothing polled, counted or tripped. +// +// Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, +// so a bare deletion would be a silent strip (ADR-0104). The shapes behind it +// leave whole — `integration/ConnectorHealth`, `integration/HealthCheckConfig`, +// `integration/CircuitBreakerConfig` in `RETIRED_DEFS_BY_MAJOR[18]`. The key +// carried no default of its own, so there is no residue window for it (its +// sub-keys' defaults were only materialized inside an authored block). Sources +// and stored rows are rewritten by the D2 conversion +// `connector-resilience-keys-removed`; the family's judgement is the D3 entry +// `connector-resilience-keys-retired`. +// +// Registered under 18, not 17: 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:health'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__status.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__status.ts new file mode 100644 index 00000000000..23e4c3978ee --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__status.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `ConnectorSchema.status` — part of the connector +// resilience family batch (see `18.integration__Connector__health.ts`). The key +// was `ConnectorStatusSchema` (`active` / `inactive` / `error` / `configuring`) +// with a `.default('inactive')`, and NOTHING read it. Measured at `origin/main` +// 3f86dc52f2 with one member-read pattern over the connector packages, the +// automation service, rest, runtime, metadata and objectql: 38 `.status` reads, +// every one on an HTTP answer, an error case or a flow-run entry, none on a +// connector def — while the same pattern finds `requestTimeoutMs`, the lit +// control, read off a connector entry or provider context five times. +// The runtime's dispatchability answer is a DIFFERENT field: the computed +// `state` (`ready` / `degraded`) that `GET /api/v1/automation/connectors` +// publishes and no authored value can set. Participation is `enabled` (and +// `provider` on a declarative instance). The only non-spec occurrences were +// WRITES — `status: 'active'` in the four shipped connector packages and +// `status: 'error'` on the automation service's degraded husk — read back by +// nothing; they were deleted in the same change. +// +// Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned +// `integration/ConnectorStatus` enum leaves via `RETIRED_DEFS_BY_MAJOR[18]`. +// +// ⭐ RETIRED-DEFAULT RESIDUE: owed and adopted — `{ status: 'inactive' }` joins +// `{ connectionTimeoutMs: 30000 }` in `CONNECTOR_RETIRED_KEY_RESIDUE` on both +// carriers (#12840's class rule, `shared/retired-key.ts`). The discriminator is +// whether a released toolchain MATERIALIZED the default into something that is +// later re-parsed, and it did: every 17.x parse emitted `status: 'inactive'` +// into every connector — authored or not — and `registerConnector` re-parses a +// def built in code, where no conversion runs. Any other value keeps the +// refusal. Sources and stored rows are rewritten by the D2 conversion +// `connector-resilience-keys-removed`. +export const entry = 'integration/Connector:status'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts new file mode 100644 index 00000000000..bcd7307b190 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// ADR-0049 enforce-or-remove on `ConnectorSchema.webhooks` — part of the +// connector resilience family batch (see `18.integration__Connector__health.ts`). +// A connector's NESTED webhook array is not the collection anything delivers: +// the stack decomposition registers a `connectors:` entry WHOLE, so a webhook +// nested in it never becomes a `webhook` metadata item, and +// `@objectstack/plugin-webhooks` materializes `sys_webhook` rows only from those +// items (the top-level `webhooks:` collection). Measured on `origin/main`: zero +// reads of a connector's own `webhooks` outside `packages/spec`, while +// `stack.webhooks` — the lit control, same scan — is read five times; the one +// test that authors a nested array +// (`bootstrap-declared-webhooks.connector-nested.test.ts`) exists to pin that it +// is NOT hoisted. And no code path emits a connector lifecycle event +// (`sync.completed`, `auth.expired`, …) for its `events` to subscribe to. +// +// Tombstoned with `retiredKey()` (non-strict schema, ADR-0104). The nested shape +// leaves whole — `integration/WebhookConfig`, `integration/WebhookEvent`, +// `integration/WebhookSignatureAlgorithm` in `RETIRED_DEFS_BY_MAJOR[18]`. The key +// carried no default, so no residue window. The D2 conversion +// `connector-resilience-keys-removed` STRIPS the array and never moves it to the +// top-level collection: that would start deliveries the connector never made — +// the author's decision, carried by the D3 entry `connector-resilience-keys-retired`. +export const entry = 'integration/Connector:webhooks'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__health.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__health.ts new file mode 100644 index 00000000000..dbf2c51dcf5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__health.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The same `health` tombstone seen through the second carrier. +// `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are SIBLINGS: each +// wraps the shared private `ConnectorBaseSchema` in the retired-default residue +// stage, so the 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. See +// `18.integration__Connector__health.ts` for the retirement record. +export const entry = 'integration/DeclarativeConnectorEntry:health'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__status.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__status.ts new file mode 100644 index 00000000000..9df4e23de3a --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__status.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The same `status` tombstone seen through the second carrier — the shape +// `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, which +// also carries the `'inactive'` residue stage (both carriers wrap +// `ConnectorBaseSchema` with the same `CONNECTOR_RETIRED_KEY_RESIDUE`). One +// tombstone, two registered keys, EXACT per-def membership (gate (b)). See +// `18.integration__Connector__status.ts` for the retirement record. +export const entry = 'integration/DeclarativeConnectorEntry:status'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__webhooks.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__webhooks.ts new file mode 100644 index 00000000000..7a3530b2c77 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__DeclarativeConnectorEntry__webhooks.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The same `webhooks` tombstone seen through the second carrier — the shape +// `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. One +// tombstone, two registered keys, EXACT per-def membership (gate (b)). See +// `18.integration__Connector__webhooks.ts` for the retirement record. +export const entry = 'integration/DeclarativeConnectorEntry:webhooks'; diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts new file mode 100644 index 00000000000..90825991fd4 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-keys-retired.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0049 enforce-or-remove — the D3 entry of the connector resilience family: +// `connector.health` (the `healthCheck` probe and the `circuitBreaker`), +// `connector.status` and the connector-nested `webhooks`, sixteen authorable keys +// retired as one batch. One D3 entry per retirement family, even when D2 is +// lossless (ruling B on #17152): the D2 conversion +// `connector-resilience-keys-removed` repairs the data, and this entry carries +// what only the author can judge. It also names the CHAIN through the same +// protocol step: `connector-health-and-trigger-durations-unit-in-key` used to +// rename `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`, and +// that half was absorbed here — the renamed key is itself removed. +export const entry: SemanticMigration = { + id: 'connector-resilience-keys-retired', + surface: 'connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — ' + + 'on a connector and on a stack connectors[] entry', + replacement: '(removed — nothing replaces the probe, the breaker or an authored status.) ' + + 'Participation is `enabled` (and `provider` on a declarative instance); whether a registered ' + + 'connector can be dispatched is the computed `state` (`ready` / `degraded`) on ' + + '`GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in ' + + 'the top-level `webhooks:` collection; probes and circuit breaking belong in the connector ' + + 'provider or an upstream gateway.', + reason: 'The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and ' + + '`webhooks` from every connector, stack entry and stored connector row, one notice per key, ' + + 'and the delete is lossless: no loop ever polled a connector endpoint, counted failures or ' + + 'tripped a breaker, no code read an authored status, and a webhook nested in a connector ' + + 'was never registered, materialized or delivered. Three judgements remain. First, a probe ' + + 'or breaker the author believed was protecting a flaky upstream never was — if that ' + + 'protection matters, it has to be built where calls are made (the connector provider) or ' + + 'in front of the upstream (a gateway). Second, `status` values like `active` or `error` ' + + 'gated nothing; an author who used `status` to switch a connector off needs `enabled: ' + + 'false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not ' + + 'moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that ' + + 'never happened before, so which of them should exist is the author\'s call — and their ' + + '`events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no ' + + 'counterpart there. The chain: in this same protocol step, ' + + '`connector-health-and-trigger-durations-unit-in-key` no longer renames ' + + '`health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that ' + + 'key lived in is removed, so an author holding either spelling ends with no key at all; ' + + 'that conversion\'s `triggers[].interval` to `intervalSeconds` rename is unaffected.', + acceptanceCriteria: 'No connector and no stack connector entry carries `health`, `status` or ' + + '`webhooks`; the parse refuses each with its prescription (a stored `status: \'inactive\'` ' + + 'default is accepted and stripped as inert residue), and no code imports ConnectorHealth, ' + + 'HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent or ' + + 'WebhookSignatureAlgorithm. Every connector dispatches exactly as it did before the ' + + 'upgrade. Each declarative connector instance the author meant to be switched off carries ' + + '`enabled: false` and is observed absent from `GET /api/v1/automation/connectors`; each ' + + 'nested webhook that is still wanted ' + + 'is declared in the top-level `webhooks:` collection and observed delivering; and each ' + + 'probe or breaker the author relied on is provided by the connector provider or a gateway ' + + 'and observed tripping against a failing upstream.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3b9b4fa90f3..96417038055 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5309,7 +5309,27 @@ const step18: MigrationStep = { + 'and stored rows) and the assembled-manifest `viewItems` channel (package export, ' + 'environment artifacts), whose registration parse would otherwise refuse an artifact ' + 'assembled before this release; a flattened overlay keeps its own `owner` / `hidden`, ' - + 'which are declared on a different door this retirement does not touch.', + + 'which are declared on a different door this retirement does not touch. ' + + 'It also retires the connector resilience family (ADR-0049 enforce-or-remove, one batch): ' + + '`connector.health` — the `healthCheck` probe (eight keys) and the `circuitBreaker` (six) — ' + + '`connector.status` and the connector-nested `webhooks`, sixteen authorable keys with no ' + + 'reader outside the spec package. No loop ever polled a connector endpoint or tripped a ' + + 'breaker; nothing read an authored `status` (the runtime publishes a computed `state`, and ' + + 'participation is `enabled`); and a webhook nested in a connector was never registered as a ' + + '`webhook` item, so it was never materialized or delivered — the top-level `webhooks:` ' + + 'collection is the delivered one. The three carrier keys are retiredKey tombstones on ' + + '`ConnectorBaseSchema`, registered under both carrier defs; `status`, defaulted ' + + '`\'inactive\'`, joins `connectionTimeoutMs` in the retired-default residue stage, because ' + + 'every 17.x parse emitted it into every connector. Seven defs leave whole — ' + + '`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, ' + + '`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` — and the D2 conversion ' + + '`connector-resilience-keys-removed` strips the three keys from `connectors[]` and stored ' + + 'rows as a pure lossless delete (the nested webhooks are stripped, never moved: moving them ' + + 'would start deliveries that never happened). It ABSORBS the breaker half of the duration ' + + 'rename above: `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` is no longer ' + + 'converted, because the whole block it lived in is now removed, and ' + + '`connector-health-and-trigger-durations-unit-in-key` keeps only `triggers[].interval` → ' + + '`intervalSeconds`.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5336,6 +5356,7 @@ const step18: MigrationStep = { 'api-endpoint-cache-ttl-to-cache-ttl-seconds', 'dashboard-refresh-interval-to-refresh-interval-seconds', 'connector-health-and-trigger-durations-unit-in-key', + 'connector-resilience-keys-removed', 'memory-persistence-auto-save-interval-to-ms', 'turso-config-timeout-to-timeout-ms', 'view-page-mount-removed', @@ -6996,6 +7017,56 @@ const step18: MigrationStep = { + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + 'both resolve to real reads at the fetch site.', }, + // ADR-0049 enforce-or-remove — the D3 entry of the connector resilience family: + // `connector.health` (the `healthCheck` probe and the `circuitBreaker`), + // `connector.status` and the connector-nested `webhooks`, sixteen authorable keys + // retired as one batch. One D3 entry per retirement family, even when D2 is + // lossless (ruling B on #17152): the D2 conversion + // `connector-resilience-keys-removed` repairs the data, and this entry carries + // what only the author can judge. It also names the CHAIN through the same + // protocol step: `connector-health-and-trigger-durations-unit-in-key` used to + // rename `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`, and + // that half was absorbed here — the renamed key is itself removed. + { + id: 'connector-resilience-keys-retired', + surface: 'connector.health (healthCheck / circuitBreaker), connector.status and connector.webhooks — ' + + 'on a connector and on a stack connectors[] entry', + replacement: '(removed — nothing replaces the probe, the breaker or an authored status.) ' + + 'Participation is `enabled` (and `provider` on a declarative instance); whether a registered ' + + 'connector can be dispatched is the computed `state` (`ready` / `degraded`) on ' + + '`GET /api/v1/automation/connectors`; a webhook that is actually delivered is declared in ' + + 'the top-level `webhooks:` collection; probes and circuit breaking belong in the connector ' + + 'provider or an upstream gateway.', + reason: 'The D2 conversion `connector-resilience-keys-removed` deletes `health`, `status` and ' + + '`webhooks` from every connector, stack entry and stored connector row, one notice per key, ' + + 'and the delete is lossless: no loop ever polled a connector endpoint, counted failures or ' + + 'tripped a breaker, no code read an authored status, and a webhook nested in a connector ' + + 'was never registered, materialized or delivered. Three judgements remain. First, a probe ' + + 'or breaker the author believed was protecting a flaky upstream never was — if that ' + + 'protection matters, it has to be built where calls are made (the connector provider) or ' + + 'in front of the upstream (a gateway). Second, `status` values like `active` or `error` ' + + 'gated nothing; an author who used `status` to switch a connector off needs `enabled: ' + + 'false` on the declarative entry instead. Third, the nested webhooks are STRIPPED, not ' + + 'moved: redeclaring one in the top-level `webhooks:` collection STARTS deliveries that ' + + 'never happened before, so which of them should exist is the author\'s call — and their ' + + '`events` (`sync.completed`, `auth.expired` and the rest) and `signatureAlgorithm` have no ' + + 'counterpart there. The chain: in this same protocol step, ' + + '`connector-health-and-trigger-durations-unit-in-key` no longer renames ' + + '`health.circuitBreaker.monitoringWindow` to `monitoringWindowMs` — the whole block that ' + + 'key lived in is removed, so an author holding either spelling ends with no key at all; ' + + 'that conversion\'s `triggers[].interval` to `intervalSeconds` rename is unaffected.', + acceptanceCriteria: 'No connector and no stack connector entry carries `health`, `status` or ' + + '`webhooks`; the parse refuses each with its prescription (a stored `status: \'inactive\'` ' + + 'default is accepted and stripped as inert residue), and no code imports ConnectorHealth, ' + + 'HealthCheckConfig, CircuitBreakerConfig, ConnectorStatus, WebhookConfig, WebhookEvent or ' + + 'WebhookSignatureAlgorithm. Every connector dispatches exactly as it did before the ' + + 'upgrade. Each declarative connector instance the author meant to be switched off carries ' + + '`enabled: false` and is observed absent from `GET /api/v1/automation/connectors`; each ' + + 'nested webhook that is still wanted ' + + 'is declared in the top-level `webhooks:` collection and observed delivering; and each ' + + 'probe or breaker the author relied on is provided by the connector provider or a gateway ' + + 'and observed tripping against a failing upstream.', + }, { id: 'cube-join-sql-and-relationship-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code @@ -16511,6 +16582,91 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #12497 / #13823 grading). 'integration/Connector:errorMapping', + // ADR-0049 enforce-or-remove on `ConnectorSchema.health` — the connector + // resilience family (one batch with `status` and the nested `webhooks`), retired + // by the maintainer's criterion for a declared-but-unenforced family: does the + // mainstream platform offer the capability? Author-configured health probes and + // circuit breakers are not connector metadata anywhere in the mainstream + // (Salesforce Named Credentials, Power Platform custom connectors, Retool / + // Appsmith resources); breakers live in API-gateway infrastructure. + // + // Measured on `origin/main` before the removal: the fourteen keys under the block + // — `healthCheck.{enabled, intervalMs, timeoutMs, endpoint, method, + // expectedStatus, unhealthyThreshold, healthyThreshold}` and + // `circuitBreaker.{enabled, failureThreshold, resetTimeoutMs, + // halfOpenMaxRequests, monitoringWindowMs, fallbackStrategy}` — have ZERO reads + // outside `packages/spec` (lit control in the same scan: `retryConfig`, the + // executed sibling policy, read 17 times in `packages/connectors` and + // `packages/services/service-automation`). Nothing polled, counted or tripped. + // + // Tombstoned with `retiredKey()`: `ConnectorSchema` is a non-strict `z.object`, + // so a bare deletion would be a silent strip (ADR-0104). The shapes behind it + // leave whole — `integration/ConnectorHealth`, `integration/HealthCheckConfig`, + // `integration/CircuitBreakerConfig` in `RETIRED_DEFS_BY_MAJOR[18]`. The key + // carried no default of its own, so there is no residue window for it (its + // sub-keys' defaults were only materialized inside an authored block). Sources + // and stored rows are rewritten by the D2 conversion + // `connector-resilience-keys-removed`; the family's judgement is the D3 entry + // `connector-resilience-keys-retired`. + // + // Registered under 18, not 17: 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:health', + // ADR-0049 enforce-or-remove on `ConnectorSchema.status` — part of the connector + // resilience family batch (see `18.integration__Connector__health.ts`). The key + // was `ConnectorStatusSchema` (`active` / `inactive` / `error` / `configuring`) + // with a `.default('inactive')`, and NOTHING read it. Measured at `origin/main` + // 3f86dc52f2 with one member-read pattern over the connector packages, the + // automation service, rest, runtime, metadata and objectql: 38 `.status` reads, + // every one on an HTTP answer, an error case or a flow-run entry, none on a + // connector def — while the same pattern finds `requestTimeoutMs`, the lit + // control, read off a connector entry or provider context five times. + // The runtime's dispatchability answer is a DIFFERENT field: the computed + // `state` (`ready` / `degraded`) that `GET /api/v1/automation/connectors` + // publishes and no authored value can set. Participation is `enabled` (and + // `provider` on a declarative instance). The only non-spec occurrences were + // WRITES — `status: 'active'` in the four shipped connector packages and + // `status: 'error'` on the automation service's degraded husk — read back by + // nothing; they were deleted in the same change. + // + // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104); the orphaned + // `integration/ConnectorStatus` enum leaves via `RETIRED_DEFS_BY_MAJOR[18]`. + // + // ⭐ RETIRED-DEFAULT RESIDUE: owed and adopted — `{ status: 'inactive' }` joins + // `{ connectionTimeoutMs: 30000 }` in `CONNECTOR_RETIRED_KEY_RESIDUE` on both + // carriers (#12840's class rule, `shared/retired-key.ts`). The discriminator is + // whether a released toolchain MATERIALIZED the default into something that is + // later re-parsed, and it did: every 17.x parse emitted `status: 'inactive'` + // into every connector — authored or not — and `registerConnector` re-parses a + // def built in code, where no conversion runs. Any other value keeps the + // refusal. Sources and stored rows are rewritten by the D2 conversion + // `connector-resilience-keys-removed`. + 'integration/Connector:status', + // ADR-0049 enforce-or-remove on `ConnectorSchema.webhooks` — part of the + // connector resilience family batch (see `18.integration__Connector__health.ts`). + // A connector's NESTED webhook array is not the collection anything delivers: + // the stack decomposition registers a `connectors:` entry WHOLE, so a webhook + // nested in it never becomes a `webhook` metadata item, and + // `@objectstack/plugin-webhooks` materializes `sys_webhook` rows only from those + // items (the top-level `webhooks:` collection). Measured on `origin/main`: zero + // reads of a connector's own `webhooks` outside `packages/spec`, while + // `stack.webhooks` — the lit control, same scan — is read five times; the one + // test that authors a nested array + // (`bootstrap-declared-webhooks.connector-nested.test.ts`) exists to pin that it + // is NOT hoisted. And no code path emits a connector lifecycle event + // (`sync.completed`, `auth.expired`, …) for its `events` to subscribe to. + // + // Tombstoned with `retiredKey()` (non-strict schema, ADR-0104). The nested shape + // leaves whole — `integration/WebhookConfig`, `integration/WebhookEvent`, + // `integration/WebhookSignatureAlgorithm` in `RETIRED_DEFS_BY_MAJOR[18]`. The key + // carried no default, so no residue window. The D2 conversion + // `connector-resilience-keys-removed` STRIPS the array and never moves it to the + // top-level collection: that would start deliveries the connector never made — + // the author's decision, carried by the D3 entry `connector-resilience-keys-retired`. + 'integration/Connector:webhooks', // #15680 (stack card 5/6 of #14478) — ruling B. `ConnectorTrigger.interval` // said "Polling interval in seconds" in prose and nothing else. A polling // cadence is exactly the number a reader guesses at, and the bare name `interval` @@ -16558,6 +16714,29 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `${defKey}:${name}` membership per def, never by radiating from a neighbour. // See `18.integration__Connector__errorMapping.ts` for the retirement record. 'integration/DeclarativeConnectorEntry:errorMapping', + // The same `health` tombstone seen through the second carrier. + // `DeclarativeConnectorEntrySchema` and `ConnectorSchema` are SIBLINGS: each + // wraps the shared private `ConnectorBaseSchema` in the retired-default residue + // stage, so the 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. See + // `18.integration__Connector__health.ts` for the retirement record. + 'integration/DeclarativeConnectorEntry:health', + // The same `status` tombstone seen through the second carrier — the shape + // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse, which + // also carries the `'inactive'` residue stage (both carriers wrap + // `ConnectorBaseSchema` with the same `CONNECTOR_RETIRED_KEY_RESIDUE`). One + // tombstone, two registered keys, EXACT per-def membership (gate (b)). See + // `18.integration__Connector__status.ts` for the retirement record. + 'integration/DeclarativeConnectorEntry:status', + // The same `webhooks` tombstone seen through the second carrier — the shape + // `stack.connectors[]` and the `PUT /meta/connector/:name` door parse. One + // tombstone, two registered keys, EXACT per-def membership (gate (b)). See + // `18.integration__Connector__webhooks.ts` for the retirement record. + 'integration/DeclarativeConnectorEntry:webhooks', // #18669 — maintainer ruling A (2026-09-17, decision batch #151 item 4): // `CompatibilityMatrixEntry.estimatedMigrationTime` said "Estimated migration // time in hours" in a source JSDoc and carried no `.describe()` at all, so the @@ -19869,6 +20048,18 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8586 / PR #8702 precedent). 'identity/ApiKey', + // `integration/CircuitBreakerConfig` (`enabled`, `failureThreshold`, + // `resetTimeoutMs`, `halfOpenMaxRequests`, `monitoringWindowMs`, + // `fallbackStrategy`, and the `monitoringWindow` rename tombstone) leaves with + // `integration/ConnectorHealth`, whose `circuitBreaker` was its only carrier. No + // state machine ever opened, half-opened or closed a breaker, and none of the + // four `fallbackStrategy` behaviours was implemented. Its `monitoringWindow` + // tombstone leaves with it: the `RETIRED_KEYS_BY_MAJOR[18]` row + // `integration/CircuitBreakerConfig:monitoringWindow` stays, which is the + // whole-def removal steady state gate (b3) of `scripts/build-schemas.ts` + // deliberately exempts. See `retired-keys/18.integration__Connector__health.ts` + // for the retirement record. + 'integration/CircuitBreakerConfig', // #14676 — `integration/ConnectorErrorCategory` (the 8-value connector-side // error category enum) left with its two carriers: `ErrorMappingRule.targetCategory` // and `ErrorMappingConfig.defaultCategory`, both retired in this same major @@ -19882,6 +20073,22 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // `retired-keys/18.integration__Connector__errorMapping.ts` for the retirement // record. 'integration/ConnectorErrorCategory', + // `integration/ConnectorHealth` (`healthCheck`, `circuitBreaker`) leaves with its + // only carrier, `ConnectorSchema.health`, tombstoned in this same major under + // ADR-0049 enforce-or-remove (`RETIRED_KEYS_BY_MAJOR[18]`). Nothing outside the + // declaring file ever parsed or constructed one, and an exported value schema + // with no consumer reads as a capability (#3950). See + // `retired-keys/18.integration__Connector__health.ts` for the retirement record. + 'integration/ConnectorHealth', + // `integration/ConnectorStatus` (`active` / `inactive` / `error` / + // `configuring`) leaves with its only carrier, `ConnectorSchema.status`, + // tombstoned in this same major under ADR-0049 enforce-or-remove. Nothing read a + // connector's `status`; the runtime's dispatchability answer is the computed + // `ConnectorState` (`ready` / `degraded`, `integration/connector-descriptor.ts`), + // which is a TypeScript type and not a published def, so nothing replaces this + // one. See `retired-keys/18.integration__Connector__status.ts` for the + // retirement record. + 'integration/ConnectorStatus', // #14676 — `integration/ErrorMappingConfig` (`rules`, `defaultCategory`, // `unmappedBehavior`, `logUnmapped`) leaves with its only carrier: // `ConnectorSchema.errorMapping`, tombstoned in this same major under ADR-0049 @@ -19905,6 +20112,38 @@ export const RETIRED_DEFS_BY_MAJOR: Readonly> // rename. See `retired-keys/18.integration__Connector__errorMapping.ts` for the // retirement record. 'integration/ErrorMappingRule', + // `integration/HealthCheckConfig` (`enabled`, `intervalMs`, `timeoutMs`, + // `endpoint`, `method`, `expectedStatus`, `unhealthyThreshold`, + // `healthyThreshold`) leaves with `integration/ConnectorHealth`, whose + // `healthCheck` was its only carrier. No loop ever polled a connector endpoint: + // the only `healthCheck` code outside `packages/spec` is the kernel's PLUGIN + // health contract — a different shape on a different subject. Its four + // `.default()`s were only ever materialized INSIDE an authored block, so there + // is no residue window on the carrier. See + // `retired-keys/18.integration__Connector__health.ts` for the retirement record. + 'integration/HealthCheckConfig', + // `integration/WebhookConfig` — the canonical `webhook` shape `.extend()`ed with + // `events` and `signatureAlgorithm` — leaves with its only carrier, + // `ConnectorSchema.webhooks`, tombstoned in this same major under ADR-0049 + // enforce-or-remove. A webhook nested in a connector was never registered, + // materialized or delivered; the delivered shape is `automation/Webhook`, which + // is unaffected. See `retired-keys/18.integration__Connector__webhooks.ts` for + // the retirement record. + 'integration/WebhookConfig', + // `integration/WebhookEvent` (`record.created` / `record.updated` / + // `record.deleted` / `sync.started` / `sync.completed` / `sync.failed` / + // `auth.expired` / `rate_limit.exceeded`) leaves with `integration/WebhookConfig`, + // whose `events` was its only carrier. No code path emits any of the connector + // lifecycle events it names. See + // `retired-keys/18.integration__Connector__webhooks.ts` for the retirement record. + 'integration/WebhookEvent', + // `integration/WebhookSignatureAlgorithm` (`hmac_sha256` / `hmac_sha512` / + // `none`) leaves with `integration/WebhookConfig`, whose `signatureAlgorithm` was + // its only carrier. A delivered webhook (the top-level `webhooks:` collection) is + // signed by the messaging outbox from its `secret`; nothing ever read this + // choice. See `retired-keys/18.integration__Connector__webhooks.ts` for the + // retirement record. + 'integration/WebhookSignatureAlgorithm', // #11825 — kernel/plugin-lifecycle-advanced.zod.ts // `AdvancedPluginLifecycleConfigSchema`, retired whole (ADR-0049 // enforce-or-remove; maintainer ruling 2026-08-25, route 2). The aggregating diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f51e1475899..4bff0f600ba 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,7 +275,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 786 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -930,19 +930,18 @@ export type Iso_identity_scim__SCIMPatchOperationSchema = Assert, z.infer< typeof M78.ConnectorActionEffectSchema > >>; export type Iso_integration_connector__ConnectorActionSchema = Assert, z.infer< typeof M78.ConnectorActionSchema > >>; export type Iso_integration_connector__ConnectorConflictResolutionSchema = Assert, z.infer< typeof M78.ConnectorConflictResolutionSchema > >>; export type Iso_integration_connector__ConnectorRetryStrategySchema = Assert, z.infer< typeof M78.ConnectorRetryStrategySchema > >>; -export type Iso_integration_connector__ConnectorStatusSchema = Assert, z.infer< typeof M78.ConnectorStatusSchema > >>; export type Iso_integration_connector__ConnectorTriggerSchema = Assert, z.infer< typeof M78.ConnectorTriggerSchema > >>; export type Iso_integration_connector__ConnectorTypeSchema = Assert, z.infer< typeof M78.ConnectorTypeSchema > >>; export type Iso_integration_connector__SyncStrategySchema = Assert, z.infer< typeof M78.SyncStrategySchema > >>; -export type Iso_integration_connector__WebhookEventSchema = Assert, z.infer< typeof M78.WebhookEventSchema > >>; -export type Iso_integration_connector__WebhookSignatureAlgorithmSchema = Assert, z.infer< typeof M78.WebhookSignatureAlgorithmSchema > >>; // kernel/cli-extension.zod.ts // Iso385 (`CLICommandContributionSchema`) left with the #12007 retirement. @@ -1658,7 +1657,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 786 isomorphic pins', () => { + it('still declares all 783 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2305,7 +2304,18 @@ describe('ADR-0122 type-alias convention', () => { // touch disjoint pins (M22's three, M14's one); #17158 landed first, so // this entry's arrow starts from its 787. The count below was re-derived // from the merged file, not added up. -1 removed. - expect(pins).toHaveLength(786); + // + // 786 -> 783 is the ADR-0049 retirement of the connector resilience family + // (`connector.health`, `connector.status` and the connector-nested + // `webhooks`): `ConnectorStatusSchema`, `WebhookEventSchema` and + // `WebhookSignatureAlgorithmSchema` left whole with their carrier keys + // (whole-def removal, `RETIRED_DEFS_BY_MAJOR[18]`), so their three M78 pins + // leave with the schemas. The other four defs of that retirement + // (`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, + // `WebhookConfig`) each carried an `XParsed` alias and were never on this + // list. The M78 slot stays occupied by the module's surviving pins. -3 + // removed. + expect(pins).toHaveLength(783); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 23a523dc6b0..020d6778745 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -26,6 +26,7 @@ "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/integration/connector-resilience-keys-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", From fc9e17c2b1f915ca3d24814bd4280460fa8e3174 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:26:18 +0000 Subject: [PATCH 02/13] wip(spec): drop the seven retired defs from the manifest and baseline; tombstone rows regenerated Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../spec/authorable-defaults/integration.json | 17 +----- .../spec/authorable-surface/integration.json | 52 +++---------------- .../json-schema.manifest/integration.json | 9 +--- 3 files changed, 9 insertions(+), 69 deletions(-) diff --git a/packages/spec/authorable-defaults/integration.json b/packages/spec/authorable-defaults/integration.json index 762e255eca5..8c656d83854 100644 --- a/packages/spec/authorable-defaults/integration.json +++ b/packages/spec/authorable-defaults/integration.json @@ -2,14 +2,9 @@ "description": "Ratchet of the DEFAULT VALUE of every authorable key in one category that has one (#4666) — what a metadata author gets when they omit the key, which for AI-authored metadata is most of the time. Sharded by category like authorable-surface/; the gate reads the whole authorable-defaults/ directory as ONE set. Each line is \": = \". Additions (a NEW key that ships with a default) are auto-recorded — commit the change. CHANGING, ADDING or REMOVING the default of a key that already existed is NOT auto-recorded: it silently alters the behaviour of already-deployed metadata, so it fails check:authorable-surface until it is declared in DEFAULT_CHANGES_BY_MAJOR (scripts/lib/default-changes.ts). Constraints are deliberately NOT recorded here — a tightened bound REJECTS a document loudly, which is a different and self-announcing class (maintainer ruling on #4666, direction B). See #4666, #4661.", "category": "integration", "defaults": [ - "integration/CircuitBreakerConfig:failureThreshold = 5", - "integration/CircuitBreakerConfig:halfOpenMaxRequests = 1", - "integration/CircuitBreakerConfig:monitoringWindowMs = 60000", - "integration/CircuitBreakerConfig:resetTimeoutMs = 30000", "integration/Connector:authentication = {\"type\":\"none\"}", "integration/Connector:enabled = true", "integration/Connector:requestTimeoutMs = 30000", - "integration/Connector:status = \"inactive\"", "integration/ConnectorFieldMapping:required = false", "integration/ConnectorFieldMapping:syncMode = \"bidirectional\"", "integration/DataSyncConfig:batchSize = 1000", @@ -21,12 +16,6 @@ "integration/DeclarativeConnectorEntry:authentication = {\"type\":\"none\"}", "integration/DeclarativeConnectorEntry:enabled = true", "integration/DeclarativeConnectorEntry:requestTimeoutMs = 30000", - "integration/DeclarativeConnectorEntry:status = \"inactive\"", - "integration/HealthCheckConfig:expectedStatus = 200", - "integration/HealthCheckConfig:healthyThreshold = 1", - "integration/HealthCheckConfig:intervalMs = 60000", - "integration/HealthCheckConfig:timeoutMs = 5000", - "integration/HealthCheckConfig:unhealthyThreshold = 3", "integration/RetryConfig:backoffMultiplier = 2", "integration/RetryConfig:initialDelayMs = 1000", "integration/RetryConfig:jitter = true", @@ -34,10 +23,6 @@ "integration/RetryConfig:maxDelayMs = 60000", "integration/RetryConfig:retryOnNetworkError = true", "integration/RetryConfig:retryableStatusCodes = [408,429,500,502,503,504]", - "integration/RetryConfig:strategy = \"exponential_backoff\"", - "integration/WebhookConfig:isActive = true", - "integration/WebhookConfig:method = \"POST\"", - "integration/WebhookConfig:signatureAlgorithm = \"hmac_sha256\"", - "integration/WebhookConfig:timeoutMs = 30000" + "integration/RetryConfig:strategy = \"exponential_backoff\"" ] } diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index d2f63a82b2c..8d7889b1581 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -2,13 +2,6 @@ "description": "Ratchet of every AUTHORABLE key in one category of the spec — what a metadata author may write, which for this platform IS the third-party API. Sharded by category (#5837) so two PRs touching different categories never share a file; the gate reads the whole authorable-surface/ directory as ONE set, so deleting a shard deletes its keys exactly as deleting lines did. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "category": "integration", "keys": [ - "integration/CircuitBreakerConfig:enabled", - "integration/CircuitBreakerConfig:failureThreshold", - "integration/CircuitBreakerConfig:fallbackStrategy", - "integration/CircuitBreakerConfig:halfOpenMaxRequests", - "integration/CircuitBreakerConfig:monitoringWindow [RETIRED]", - "integration/CircuitBreakerConfig:monitoringWindowMs", - "integration/CircuitBreakerConfig:resetTimeoutMs", "integration/Connector:_lock", "integration/Connector:_lockDocsUrl", "integration/Connector:_lockReason", @@ -24,7 +17,7 @@ "integration/Connector:enabled", "integration/Connector:errorMapping [RETIRED]", "integration/Connector:fieldMappings", - "integration/Connector:health", + "integration/Connector:health [RETIRED]", "integration/Connector:icon", "integration/Connector:label", "integration/Connector:metadata", @@ -34,11 +27,11 @@ "integration/Connector:rateLimitConfig [RETIRED]", "integration/Connector:requestTimeoutMs", "integration/Connector:retryConfig", - "integration/Connector:status", + "integration/Connector:status [RETIRED]", "integration/Connector:syncConfig", "integration/Connector:triggers", "integration/Connector:type", - "integration/Connector:webhooks", + "integration/Connector:webhooks [RETIRED]", "integration/ConnectorAction:description", "integration/ConnectorAction:effect", "integration/ConnectorAction:inputSchema", @@ -52,8 +45,6 @@ "integration/ConnectorFieldMapping:syncMode", "integration/ConnectorFieldMapping:target", "integration/ConnectorFieldMapping:transform [RETIRED]", - "integration/ConnectorHealth:circuitBreaker", - "integration/ConnectorHealth:healthCheck", "integration/ConnectorInstanceAPIKeyAuth:credentialRef", "integration/ConnectorInstanceAPIKeyAuth:headerName", "integration/ConnectorInstanceAPIKeyAuth:paramName", @@ -93,7 +84,7 @@ "integration/DeclarativeConnectorEntry:enabled", "integration/DeclarativeConnectorEntry:errorMapping [RETIRED]", "integration/DeclarativeConnectorEntry:fieldMappings", - "integration/DeclarativeConnectorEntry:health", + "integration/DeclarativeConnectorEntry:health [RETIRED]", "integration/DeclarativeConnectorEntry:icon", "integration/DeclarativeConnectorEntry:label", "integration/DeclarativeConnectorEntry:metadata", @@ -103,19 +94,11 @@ "integration/DeclarativeConnectorEntry:rateLimitConfig [RETIRED]", "integration/DeclarativeConnectorEntry:requestTimeoutMs", "integration/DeclarativeConnectorEntry:retryConfig", - "integration/DeclarativeConnectorEntry:status", + "integration/DeclarativeConnectorEntry:status [RETIRED]", "integration/DeclarativeConnectorEntry:syncConfig", "integration/DeclarativeConnectorEntry:triggers", "integration/DeclarativeConnectorEntry:type", - "integration/DeclarativeConnectorEntry:webhooks", - "integration/HealthCheckConfig:enabled", - "integration/HealthCheckConfig:endpoint", - "integration/HealthCheckConfig:expectedStatus", - "integration/HealthCheckConfig:healthyThreshold", - "integration/HealthCheckConfig:intervalMs", - "integration/HealthCheckConfig:method", - "integration/HealthCheckConfig:timeoutMs", - "integration/HealthCheckConfig:unhealthyThreshold", + "integration/DeclarativeConnectorEntry:webhooks [RETIRED]", "integration/RetryConfig:backoffMultiplier", "integration/RetryConfig:initialDelayMs", "integration/RetryConfig:jitter", @@ -123,27 +106,6 @@ "integration/RetryConfig:maxDelayMs", "integration/RetryConfig:retryOnNetworkError", "integration/RetryConfig:retryableStatusCodes", - "integration/RetryConfig:strategy", - "integration/WebhookConfig:_lock", - "integration/WebhookConfig:_lockDocsUrl", - "integration/WebhookConfig:_lockReason", - "integration/WebhookConfig:_lockSource", - "integration/WebhookConfig:_packageId", - "integration/WebhookConfig:_packageVersion", - "integration/WebhookConfig:_provenance", - "integration/WebhookConfig:description", - "integration/WebhookConfig:events", - "integration/WebhookConfig:headers", - "integration/WebhookConfig:isActive", - "integration/WebhookConfig:label", - "integration/WebhookConfig:method", - "integration/WebhookConfig:name", - "integration/WebhookConfig:object", - "integration/WebhookConfig:protection", - "integration/WebhookConfig:secret", - "integration/WebhookConfig:signatureAlgorithm", - "integration/WebhookConfig:timeoutMs", - "integration/WebhookConfig:triggers", - "integration/WebhookConfig:url" + "integration/RetryConfig:strategy" ] } diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index df9c8f45a63..db60044df8f 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -2,29 +2,22 @@ "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts for one category. Sharded by category (#5837); the gate reads the whole json-schema.manifest/ directory as ONE set. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema. DELETING a key is gated too (#4725): the removal is measured against this directory at the merge base with origin/main — which the commit under test cannot rewrite — and every def that leaves the published set must be declared in RETIRED_DEFS_BY_MAJOR (src/migrations/registry.ts), or in RENAMED_DEFS (scripts/lib/renamed-defs.ts) when it is a rename rather than a removal. See #2978, #4725.", "category": "integration", "schemas": [ - "integration/CircuitBreakerConfig", "integration/Connector", "integration/ConnectorAction", "integration/ConnectorActionEffect", "integration/ConnectorConflictResolution", "integration/ConnectorFieldMapping", - "integration/ConnectorHealth", "integration/ConnectorInstanceAPIKeyAuth", "integration/ConnectorInstanceAuth", "integration/ConnectorInstanceBasicAuth", "integration/ConnectorInstanceBearerAuth", "integration/ConnectorInstanceNoAuth", "integration/ConnectorRetryStrategy", - "integration/ConnectorStatus", "integration/ConnectorTrigger", "integration/ConnectorType", "integration/DataSyncConfig", "integration/DeclarativeConnectorEntry", - "integration/HealthCheckConfig", "integration/RetryConfig", - "integration/SyncStrategy", - "integration/WebhookConfig", - "integration/WebhookEvent", - "integration/WebhookSignatureAlgorithm" + "integration/SyncStrategy" ] } From e0fdac26ec6c8930aecbc843331982cebdd42322 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:38:32 +0000 Subject: [PATCH 03/13] wip(spec): regenerate api-surface, export-origins, declaration-map, reference docs, strictness counts and the shrunk test-typecheck debt Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- content/docs/references/index.mdx | 10 +- .../docs/references/integration/connector.mdx | 273 ++---------------- ...07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/api-surface/integration.json | 18 -- .../spec/declaration-map/integration.json | 16 +- packages/spec/export-origins/integration.json | 18 -- packages/spec/test-typecheck-debt.json | 4 +- 7 files changed, 35 insertions(+), 306 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index fbd5465c59f..d2960fedf93 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1523 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -24,7 +24,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 14 | 75 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 24 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 17 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 159 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1523** | 14 protocol modules | +| **Total** | **196** | **1516** | 14 protocol modules | --- @@ -186,13 +186,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 24 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 17 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `CircuitBreakerConfig`, `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorConflictResolution`, `ConnectorFieldMapping`, `ConnectorHealth`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorStatus`, `ConnectorTrigger`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `HealthCheckConfig`, `RetryConfig`, `SyncStrategy`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorConflictResolution`, `ConnectorFieldMapping`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorTrigger`, `ConnectorType`, `DataSyncConfig`, `DeclarativeConnectorEntry`, `RetryConfig`, `SyncStrategy` | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index f8dccb6e1fa..96eba3d58f9 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -20,11 +20,13 @@ reason, that no engine ever executed them: L1 "Simple Sync" - **Enterprise Connector** (THIS FILE) - System integrators - Full SAP integration; connector-attached sync via `syncConfig` **SCOPE: Most comprehensive integration layer.** -Includes authentication, webhooks, field mapping, bidirectional sync, -retry policies, and complete lifecycle management. +Includes authentication, field mapping, bidirectional sync, retry policies, +and complete lifecycle management. This protocol supports multiple authentication strategies, bidirectional sync, -field mapping, webhooks, and comprehensive retry and resilience policies. +field mapping, and an executed retry policy. It declares no health probe, no +circuit breaker, no authored status and no webhooks of its own — see "What +this layer does NOT provide" below. ## What this layer does NOT provide @@ -55,10 +57,18 @@ 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. -⛔ **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. +**There is no connector health probe and no circuit breaker.** The +`health` block (`healthCheck` and `circuitBreaker`) was removed in +`@objectstack/spec` 17 (ADR-0049 enforce-or-remove) together with the +authored `status` and the nested `webhooks` array: nothing ever scheduled a +probe, opened a breaker, read an authored status or delivered a webhook +declared inside a connector. Implement probes and circuit breaking in the +connector provider or an upstream gateway; whether a registered connector can +be dispatched is the computed `state` (`ready` / `degraded`) that +`GET /api/v1/automation/connectors` reports; and a webhook that is actually +delivered is declared in the stack's top-level `webhooks:` collection. The +"REMOVED: `health`, `status` and the nested `webhooks`" section below records +the measurement. `connectionTimeoutMs` used to be the second exception and is now **removed** (ADR-0049, the narrower second decision that surface was owed): it was @@ -113,12 +123,11 @@ Authentication is now imported from the canonical `auth/config.zod.ts`. - Building enterprise-grade connectors (e.g., Salesforce, SAP, Oracle) - Complex OAuth2/SAML authentication required - Bidirectional sync with field mapping (`dataType` / `syncMode` per field — it moves values, it does not transform them) -- Webhook management required - Full CRUD operations and data synchronization - Need comprehensive retry strategies and error handling **Examples:** -- Full Salesforce integration with webhooks +- Full Salesforce integration - SAP ERP connector with CDC (Change Data Capture) - Microsoft Dynamics 365 connector @@ -153,32 +162,13 @@ an exception for itself.) ## TypeScript Usage ```typescript -import { CircuitBreakerConfigSchema, ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorConflictResolutionSchema, ConnectorFieldMappingSchema, ConnectorHealthSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorStatusSchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, HealthCheckConfigSchema, RetryConfigSchema, SyncStrategySchema, WebhookConfigSchema, WebhookEventSchema, WebhookSignatureAlgorithmSchema } from '@objectstack/spec/integration'; -import type { CircuitBreakerConfig, Connector, ConnectorAction, ConnectorActionEffect, ConnectorConflictResolution, ConnectorFieldMapping, ConnectorHealth, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorRetryStrategy, ConnectorStatus, ConnectorTrigger, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, HealthCheckConfig, RetryConfig, SyncStrategy, WebhookConfig, WebhookEvent, WebhookSignatureAlgorithm } from '@objectstack/spec/integration'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorConflictResolutionSchema, ConnectorFieldMappingSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorTriggerSchema, ConnectorTypeSchema, DataSyncConfigSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema, SyncStrategySchema } from '@objectstack/spec/integration'; +import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorConflictResolution, ConnectorFieldMapping, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorRetryStrategy, ConnectorTrigger, ConnectorType, DataSyncConfig, DeclarativeConnectorEntry, RetryConfig, SyncStrategy } from '@objectstack/spec/integration'; // Validate data -const result = CircuitBreakerConfigSchema.parse(data); +const result = ConnectorSchema.parse(data); ``` ---- - -## CircuitBreakerConfig - -Circuit breaker configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable circuit breaker | -| **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | -| **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | -| **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | -| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | - - --- ## Connector @@ -200,15 +190,15 @@ Circuit breaker configuration | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | | **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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** | `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 | +| **status** | `never` | optional | [REMOVED] `connector.status` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `status: 'active'` neither enabled nor advertised a connector, and `'error'` or `'configuring'` changed nothing either. Delete the key; the `ConnectorStatus` enum leaves with it. On a declarative entry, `enabled: false` is what withdraws a materialized instance or marks a catalog-only descriptor, and whether a registered connector can be dispatched is computed by the runtime and reported as `state` (`ready` or `degraded`) on `GET /api/v1/automation/connectors` — no authored value sets it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | | **errorMapping** | `never` | optional | [REMOVED] `connector.errorMapping` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no provider, dispatcher or materializer mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone (that spelling is the live API-error channel, `ApiError.userMessage`, which a thrown HTTP error declares — not connector metadata). Delete the key; the whole shape leaves with it (`ErrorMappingConfig`, `ErrorMappingRule` and the `ConnectorErrorCategory` enum). There is no replacement, because no error-mapping engine exists: a connector's failures reach callers as the provider's own errors (ADR-0097). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **health** | `never` | optional | [REMOVED] `connector.health` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no connector health probe or circuit breaker ever existed: nothing scheduled a `healthCheck` request, counted consecutive failures against a threshold, or opened, half-opened or closed a `circuitBreaker`, and no `fallbackStrategy` was ever applied, so every key in the block configured nothing. That includes `circuitBreaker.monitoringWindowMs` and the `monitoringWindow` spelling it was renamed from: the renamed key is removed with the rest. Delete the key; the whole shape leaves with it (`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`). Whether a connector can be dispatched is computed, not authored: `GET /api/v1/automation/connectors` reports each connector's `state` (`ready` or `degraded`). Put health probes and circuit breaking in the connector provider or an upstream gateway. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **metadata** | `Record` | optional | Custom connector metadata | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -327,32 +317,6 @@ Circuit breaker configuration | **required** | `boolean` | optional (default: `false`) | Field is required | | **syncMode** | `Enum<'read_only' \| 'write_only' \| 'bidirectional'>` | optional (default: `"bidirectional"`) | Sync mode | -### Nested Shape: `Connector.webhooks[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Webhook name, unique per organization (lowercase snake_case) | -| **label** | `string` | optional | Human-readable webhook label | -| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook | -| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution | -| **url** | `string` | ✅ | External webhook endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | optional (default: `"POST"`) | HTTP method | -| **headers** | `Record` | optional | Custom HTTP headers | -| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | -| **secret** | `string` | optional | Signing secret for HMAC signature verification | -| **isActive** | `boolean` | optional (default: `true`) | Whether webhook is active | -| **description** | `string` | optional | Webhook description | -| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this webhook. | -| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | -| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | -| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | -| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | -| **_packageId** | `string` | optional | Owning package machine id. | -| **_packageVersion** | `string` | optional | Owning package version. | -| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to | -| **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | - ### Nested Shape: `Connector.retryConfig` | Property | Type | Required | Description | @@ -366,13 +330,6 @@ Circuit breaker configuration | **retryOnNetworkError** | `boolean` | optional (default: `true`) | Retry on network errors | | **jitter** | `boolean` | optional (default: `true`) | Add jitter to retry delays | -### Nested Shape: `Connector.health` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number; endpoint?: string; … }` | optional | Health check configuration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutMs: number; halfOpenMaxRequests: number; … }` | optional | Circuit breaker configuration | - --- @@ -433,45 +390,6 @@ Conflict resolution strategy | **syncMode** | `Enum<'read_only' \| 'write_only' \| 'bidirectional'>` | optional (default: `"bidirectional"`) | Sync mode | ---- - -## ConnectorHealth - -Connector health configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number; endpoint?: string; … }` | optional | Health check configuration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutMs: number; halfOpenMaxRequests: number; … }` | optional | Circuit breaker configuration | - -### Nested Shape: `ConnectorHealth.healthCheck` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable health checks | -| **intervalMs** | `number` | optional (default: `60000`) | Health check interval in milliseconds | -| **timeoutMs** | `number` | optional (default: `5000`) | Health check timeout in milliseconds | -| **endpoint** | `string` | optional | Health check endpoint path | -| **method** | `Enum<'GET' \| 'HEAD' \| 'OPTIONS'>` | optional | HTTP method for health check | -| **expectedStatus** | `number` | optional (default: `200`) | Expected HTTP status code | -| **unhealthyThreshold** | `number` | optional (default: `3`) | Consecutive failures before marking unhealthy | -| **healthyThreshold** | `number` | optional (default: `1`) | Consecutive successes before marking healthy | - -### Nested Shape: `ConnectorHealth.circuitBreaker` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable circuit breaker | -| **failureThreshold** | `number` | optional (default: `5`) | Failures before opening circuit | -| **resetTimeoutMs** | `number` | optional (default: `30000`) | Time in open state before half-open | -| **halfOpenMaxRequests** | `number` | optional (default: `1`) | Requests allowed in half-open state | -| **monitoringWindowMs** | `number` | optional (default: `60000`) | Rolling window for failure count in ms | -| **monitoringWindow** | `never` | optional | [REMOVED] `CircuitBreakerConfig.monitoringWindow` was renamed to `monitoringWindowMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `monitoringWindowMs`; the value (milliseconds) and the 60000 default are unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **fallbackStrategy** | `Enum<'cache' \| 'default_value' \| 'error' \| 'queue'>` | optional | Fallback strategy when circuit is open | - - --- ## ConnectorInstanceAPIKeyAuth @@ -599,20 +517,6 @@ Retry strategy * `no_retry` ---- - -## ConnectorStatus - -Connector status - -### Allowed Values - -* `active` -* `inactive` -* `error` -* `configuring` - - --- ## ConnectorTrigger @@ -684,15 +588,15 @@ Connector type | **triggers** | `{ key: string; label: string; description?: string; type: Enum<'polling' \| 'webhook'>; … }[]` | optional | Trigger definitions | | **syncConfig** | `{ strategy: Enum<'full' \| 'incremental' \| 'upsert' \| 'append_only'>; direction: Enum<'import' \| 'export' \| 'bidirectional'>; realtimeSync: boolean; timestampField?: string; … }` | optional | Data sync configuration | | **fieldMappings** | `{ source: string; target: string; defaultValue?: any; dataType?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'datetime' \| 'json' \| 'array'>; … }[]` | optional | Field mapping rules | -| **webhooks** | `{ name: string; label?: string; object?: string; triggers?: Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]; … }[]` | optional | Webhook configurations | +| **webhooks** | `never` | optional | [REMOVED] `connector.webhooks` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a webhook nested inside a connector was never registered as a `webhook` item, so it was never materialized into `sys_webhook` and never delivered, and nothing emits the connector events its `events` list could name (`sync.completed`, `auth.expired` and the rest). Delete the key; the nested shape leaves with it (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). To have a webhook actually sent, declare it in the stack's top-level `webhooks:` collection, which is materialized into `sys_webhook` and delivered on record events — note that doing so STARTS deliveries this connector never made. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **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** | `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 | +| **status** | `never` | optional | [REMOVED] `connector.status` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: `status: 'active'` neither enabled nor advertised a connector, and `'error'` or `'configuring'` changed nothing either. Delete the key; the `ConnectorStatus` enum leaves with it. On a declarative entry, `enabled: false` is what withdraws a materialized instance or marks a catalog-only descriptor, and whether a registered connector can be dispatched is computed by the runtime and reported as `state` (`ready` or `degraded`) on `GET /api/v1/automation/connectors` — no authored value sets it. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **enabled** | `boolean` | optional (default: `true`) | Enable connector. On declarative stack entries, false marks a deliberate catalog-only descriptor. | | **errorMapping** | `never` | optional | [REMOVED] `connector.errorMapping` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: no provider, dispatcher or materializer mapped an external error through the rules, so `unmappedBehavior` configured nothing and a rule's `userMessage` was never shown to anyone (that spelling is the live API-error channel, `ApiError.userMessage`, which a thrown HTTP error declares — not connector metadata). Delete the key; the whole shape leaves with it (`ErrorMappingConfig`, `ErrorMappingRule` and the `ConnectorErrorCategory` enum). There is no replacement, because no error-mapping engine exists: a connector's failures reach callers as the provider's own errors (ADR-0097). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | -| **health** | `{ healthCheck?: object; circuitBreaker?: object }` | optional | Health and resilience configuration | +| **health** | `never` | optional | [REMOVED] `connector.health` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no connector health probe or circuit breaker ever existed: nothing scheduled a `healthCheck` request, counted consecutive failures against a threshold, or opened, half-opened or closed a `circuitBreaker`, and no `fallbackStrategy` was ever applied, so every key in the block configured nothing. That includes `circuitBreaker.monitoringWindowMs` and the `monitoringWindow` spelling it was renamed from: the renamed key is removed with the rest. Delete the key; the whole shape leaves with it (`ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`). Whether a connector can be dispatched is computed, not authored: `GET /api/v1/automation/connectors` reports each connector's `state` (`ready` or `degraded`). Put health probes and circuit breaking in the connector provider or an upstream gateway. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **metadata** | `Record` | optional | Custom connector metadata | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -811,32 +715,6 @@ Connector type | **required** | `boolean` | optional (default: `false`) | Field is required | | **syncMode** | `Enum<'read_only' \| 'write_only' \| 'bidirectional'>` | optional (default: `"bidirectional"`) | Sync mode | -### Nested Shape: `DeclarativeConnectorEntry.webhooks[number]` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Webhook name, unique per organization (lowercase snake_case) | -| **label** | `string` | optional | Human-readable webhook label | -| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook | -| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution | -| **url** | `string` | ✅ | External webhook endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | optional (default: `"POST"`) | HTTP method | -| **headers** | `Record` | optional | Custom HTTP headers | -| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | -| **secret** | `string` | optional | Signing secret for HMAC signature verification | -| **isActive** | `boolean` | optional (default: `true`) | Whether webhook is active | -| **description** | `string` | optional | Webhook description | -| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this webhook. | -| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | -| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | -| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | -| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | -| **_packageId** | `string` | optional | Owning package machine id. | -| **_packageVersion** | `string` | optional | Owning package version. | -| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| …>[]` | optional | Connector events to subscribe to | -| **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | - ### Nested Shape: `DeclarativeConnectorEntry.retryConfig` | Property | Type | Required | Description | @@ -850,33 +728,6 @@ Connector type | **retryOnNetworkError** | `boolean` | optional (default: `true`) | Retry on network errors | | **jitter** | `boolean` | optional (default: `true`) | Add jitter to retry delays | -### Nested Shape: `DeclarativeConnectorEntry.health` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **healthCheck** | `{ enabled: boolean; intervalMs: number; timeoutMs: number; endpoint?: string; … }` | optional | Health check configuration | -| **circuitBreaker** | `{ enabled: boolean; failureThreshold: number; resetTimeoutMs: number; halfOpenMaxRequests: number; … }` | optional | Circuit breaker configuration | - - ---- - -## HealthCheckConfig - -Health check configuration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable health checks | -| **intervalMs** | `number` | optional (default: `60000`) | Health check interval in milliseconds | -| **timeoutMs** | `number` | optional (default: `5000`) | Health check timeout in milliseconds | -| **endpoint** | `string` | optional | Health check endpoint path | -| **method** | `Enum<'GET' \| 'HEAD' \| 'OPTIONS'>` | optional | HTTP method for health check | -| **expectedStatus** | `number` | optional (default: `200`) | Expected HTTP status code | -| **unhealthyThreshold** | `number` | optional (default: `3`) | Consecutive failures before marking unhealthy | -| **healthyThreshold** | `number` | optional (default: `1`) | Consecutive successes before marking healthy | - --- @@ -912,73 +763,3 @@ Synchronization strategy --- -## WebhookConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Webhook name, unique per organization (lowercase snake_case) | -| **label** | `string` | optional | Human-readable webhook label | -| **object** | `string` | optional | Object whose record events (create/update/delete, bulk_update/bulk_delete) trigger this webhook | -| **triggers** | `Enum<'create' \| 'update' \| 'delete' \| 'bulk_update' \| 'bulk_delete'>[]` | optional | Events that trigger execution | -| **url** | `string` | ✅ | External webhook endpoint URL | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>` | optional (default: `"POST"`) | HTTP method | -| **headers** | `Record` | optional | Custom HTTP headers | -| **timeoutMs** | `integer` | optional (default: `30000`) | Request timeout in milliseconds | -| **secret** | `string` | optional | Signing secret for HMAC signature verification | -| **isActive** | `boolean` | optional (default: `true`) | Whether webhook is active | -| **description** | `string` | optional | Webhook description | -| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this webhook. | -| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | -| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | -| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | -| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | -| **_packageId** | `string` | optional | Owning package machine id. | -| **_packageVersion** | `string` | optional | Owning package version. | -| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -| **events** | `Enum<'record.created' \| 'record.updated' \| 'record.deleted' \| 'sync.started' \| 'sync.completed' \| 'sync.failed' \| 'auth.expired' \| 'rate_limit.exceeded'>[]` | optional | Connector events to subscribe to | -| **signatureAlgorithm** | `Enum<'hmac_sha256' \| 'hmac_sha512' \| 'none'>` | optional (default: `"hmac_sha256"`) | Webhook signature algorithm | - -### Nested Shape: `WebhookConfig.protection` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | ✅ | Lock policy — none \| no-overlay \| no-delete \| full. | -| **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | -| **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | - - ---- - -## WebhookEvent - -Webhook event type - -### Allowed Values - -* `record.created` -* `record.updated` -* `record.deleted` -* `sync.started` -* `sync.completed` -* `sync.failed` -* `auth.expired` -* `rate_limit.exceeded` - - ---- - -## WebhookSignatureAlgorithm - -Webhook signature algorithm - -### Allowed Values - -* `hmac_sha256` -* `hmac_sha512` -* `none` - - ---- - diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 6f730cddc09..6c009f3bdc3 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -259,7 +259,7 @@ directory rather than per file. | `ai/` | 78 | | `api/` | 432 | | `identity/` | 32 | -| `integration/` | 8 | +| `integration/` | 5 | | `kernel/` | 247 | | `marketplace/` | 29 | | `qa/` | 6 | diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index 29eb8e0fdfe..4d043368c97 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -3,9 +3,6 @@ "entry": "./integration", "exports": [ "CONNECTOR_UPSTREAM_UNAVAILABLE (const)", - "CircuitBreakerConfig (type)", - "CircuitBreakerConfigParsed (type)", - "CircuitBreakerConfigSchema (const)", "Connector (type)", "ConnectorAction (type)", "ConnectorActionDescriptor (interface)", @@ -19,9 +16,6 @@ "ConnectorFieldMapping (type)", "ConnectorFieldMappingParsed (type)", "ConnectorFieldMappingSchema (const)", - "ConnectorHealth (type)", - "ConnectorHealthParsed (type)", - "ConnectorHealthSchema (const)", "ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema (const)", "ConnectorInstanceAuth (type)", @@ -42,8 +36,6 @@ "ConnectorRetryStrategySchema (const)", "ConnectorSchema (const)", "ConnectorState (type)", - "ConnectorStatus (type)", - "ConnectorStatusSchema (const)", "ConnectorTrigger (type)", "ConnectorTriggerSchema (const)", "ConnectorType (type)", @@ -55,9 +47,6 @@ "DeclarativeConnectorEntry (type)", "DeclarativeConnectorEntryParsed (type)", "DeclarativeConnectorEntrySchema (const)", - "HealthCheckConfig (type)", - "HealthCheckConfigParsed (type)", - "HealthCheckConfigSchema (const)", "ResilientFetchOptions (interface)", "ResolvedConnectorAuth (type)", "RetryConfig (type)", @@ -65,13 +54,6 @@ "RetryConfigSchema (const)", "SyncStrategy (type)", "SyncStrategySchema (const)", - "WebhookConfig (type)", - "WebhookConfigParsed (type)", - "WebhookConfigSchema (const)", - "WebhookEvent (type)", - "WebhookEventSchema (const)", - "WebhookSignatureAlgorithm (type)", - "WebhookSignatureAlgorithmSchema (const)", "connectorFetchOptions (function)", "defineConnector (function)", "isConnectorUpstreamUnavailable (function)" diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index 17b4347b324..3cfad5fd15f 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -2,8 +2,6 @@ "description": "TS declaration name → spec registry name (def key) for the schemas @objectstack/spec publishes: entries['ObjectSchemaBase'] === 'data/Object'. Composed from json-schema.manifest/ (the def keys), export-origins/ (which declaration each export resolves to), and a syntactic unwinding of wrapper initializers that recovers module-private base declarations (the ObjectSchemaBase case — see scripts/build-declaration-map.ts). A name that maps to two different def keys is DROPPED into `collisions` rather than guessed, so a lookup miss means \"not known to be an authorable container\". Consumers hold a changed line’s enclosing declaration name and ask which authorable container it declares (docs-audit anchor qualification is the funding one). Generated — never hand-edited; regenerate with `pnpm --filter @objectstack/spec gen:declaration-map` and read the diff.", "category": "integration", "entries": { - "CircuitBreakerConfig": "integration/CircuitBreakerConfig", - "CircuitBreakerConfigSchema": "integration/CircuitBreakerConfig", "Connector": "integration/Connector", "ConnectorAction": "integration/ConnectorAction", "ConnectorActionEffect": "integration/ConnectorActionEffect", @@ -13,8 +11,6 @@ "ConnectorConflictResolutionSchema": "integration/ConnectorConflictResolution", "ConnectorFieldMapping": "integration/ConnectorFieldMapping", "ConnectorFieldMappingSchema": "integration/ConnectorFieldMapping", - "ConnectorHealth": "integration/ConnectorHealth", - "ConnectorHealthSchema": "integration/ConnectorHealth", "ConnectorInstanceAPIKeyAuth": "integration/ConnectorInstanceAPIKeyAuth", "ConnectorInstanceAPIKeyAuthSchema": "integration/ConnectorInstanceAPIKeyAuth", "ConnectorInstanceAuth": "integration/ConnectorInstanceAuth", @@ -28,8 +24,6 @@ "ConnectorRetryStrategy": "integration/ConnectorRetryStrategy", "ConnectorRetryStrategySchema": "integration/ConnectorRetryStrategy", "ConnectorSchema": "integration/Connector", - "ConnectorStatus": "integration/ConnectorStatus", - "ConnectorStatusSchema": "integration/ConnectorStatus", "ConnectorTrigger": "integration/ConnectorTrigger", "ConnectorTriggerSchema": "integration/ConnectorTrigger", "ConnectorType": "integration/ConnectorType", @@ -38,18 +32,10 @@ "DataSyncConfigSchema": "integration/DataSyncConfig", "DeclarativeConnectorEntry": "integration/DeclarativeConnectorEntry", "DeclarativeConnectorEntrySchema": "integration/DeclarativeConnectorEntry", - "HealthCheckConfig": "integration/HealthCheckConfig", - "HealthCheckConfigSchema": "integration/HealthCheckConfig", "RetryConfig": "integration/RetryConfig", "RetryConfigSchema": "integration/RetryConfig", "SyncStrategy": "integration/SyncStrategy", - "SyncStrategySchema": "integration/SyncStrategy", - "WebhookConfig": "integration/WebhookConfig", - "WebhookConfigSchema": "integration/WebhookConfig", - "WebhookEvent": "integration/WebhookEvent", - "WebhookEventSchema": "integration/WebhookEvent", - "WebhookSignatureAlgorithm": "integration/WebhookSignatureAlgorithm", - "WebhookSignatureAlgorithmSchema": "integration/WebhookSignatureAlgorithm" + "SyncStrategySchema": "integration/SyncStrategy" }, "collisions": [] } diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index 75beac24d0d..5f7f34c7868 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -3,9 +3,6 @@ "entry": "./integration", "exports": { "CONNECTOR_UPSTREAM_UNAVAILABLE": "src/integration/connector-provider-errors.ts#CONNECTOR_UPSTREAM_UNAVAILABLE (const)", - "CircuitBreakerConfig": "src/integration/connector.zod.ts#CircuitBreakerConfig (type)", - "CircuitBreakerConfigParsed": "src/integration/connector.zod.ts#CircuitBreakerConfigParsed (type)", - "CircuitBreakerConfigSchema": "src/integration/connector.zod.ts#CircuitBreakerConfigSchema (const)", "Connector": "src/integration/connector.zod.ts#Connector (type)", "ConnectorAction": "src/integration/connector.zod.ts#ConnectorAction (type)", "ConnectorActionDescriptor": "src/integration/connector-descriptor.ts#ConnectorActionDescriptor (interface)", @@ -19,9 +16,6 @@ "ConnectorFieldMapping": "src/integration/connector.zod.ts#ConnectorFieldMapping (type)", "ConnectorFieldMappingParsed": "src/integration/connector.zod.ts#ConnectorFieldMappingParsed (type)", "ConnectorFieldMappingSchema": "src/integration/connector.zod.ts#ConnectorFieldMappingSchema (const)", - "ConnectorHealth": "src/integration/connector.zod.ts#ConnectorHealth (type)", - "ConnectorHealthParsed": "src/integration/connector.zod.ts#ConnectorHealthParsed (type)", - "ConnectorHealthSchema": "src/integration/connector.zod.ts#ConnectorHealthSchema (const)", "ConnectorInstanceAPIKeyAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuthSchema (const)", "ConnectorInstanceAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAuth (type)", @@ -42,8 +36,6 @@ "ConnectorRetryStrategySchema": "src/integration/connector.zod.ts#ConnectorRetryStrategySchema (const)", "ConnectorSchema": "src/integration/connector.zod.ts#ConnectorSchema (const)", "ConnectorState": "src/integration/connector-descriptor.ts#ConnectorState (type)", - "ConnectorStatus": "src/integration/connector.zod.ts#ConnectorStatus (type)", - "ConnectorStatusSchema": "src/integration/connector.zod.ts#ConnectorStatusSchema (const)", "ConnectorTrigger": "src/integration/connector.zod.ts#ConnectorTrigger (type)", "ConnectorTriggerSchema": "src/integration/connector.zod.ts#ConnectorTriggerSchema (const)", "ConnectorType": "src/integration/connector.zod.ts#ConnectorType (type)", @@ -55,9 +47,6 @@ "DeclarativeConnectorEntry": "src/integration/connector.zod.ts#DeclarativeConnectorEntry (type)", "DeclarativeConnectorEntryParsed": "src/integration/connector.zod.ts#DeclarativeConnectorEntryParsed (type)", "DeclarativeConnectorEntrySchema": "src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema (const)", - "HealthCheckConfig": "src/integration/connector.zod.ts#HealthCheckConfig (type)", - "HealthCheckConfigParsed": "src/integration/connector.zod.ts#HealthCheckConfigParsed (type)", - "HealthCheckConfigSchema": "src/integration/connector.zod.ts#HealthCheckConfigSchema (const)", "ResilientFetchOptions": "src/shared/resilient-fetch.ts#ResilientFetchOptions (interface)", "ResolvedConnectorAuth": "src/shared/connector-auth.zod.ts#ResolvedConnectorAuth (type)", "RetryConfig": "src/integration/connector.zod.ts#RetryConfig (type)", @@ -65,13 +54,6 @@ "RetryConfigSchema": "src/integration/connector.zod.ts#RetryConfigSchema (const)", "SyncStrategy": "src/integration/connector.zod.ts#SyncStrategy (type)", "SyncStrategySchema": "src/integration/connector.zod.ts#SyncStrategySchema (const)", - "WebhookConfig": "src/integration/connector.zod.ts#WebhookConfig (type)", - "WebhookConfigParsed": "src/integration/connector.zod.ts#WebhookConfigParsed (type)", - "WebhookConfigSchema": "src/integration/connector.zod.ts#WebhookConfigSchema (const)", - "WebhookEvent": "src/integration/connector.zod.ts#WebhookEvent (type)", - "WebhookEventSchema": "src/integration/connector.zod.ts#WebhookEventSchema (const)", - "WebhookSignatureAlgorithm": "src/integration/connector.zod.ts#WebhookSignatureAlgorithm (type)", - "WebhookSignatureAlgorithmSchema": "src/integration/connector.zod.ts#WebhookSignatureAlgorithmSchema (const)", "connectorFetchOptions": "src/integration/connector-fetch-policy.ts#connectorFetchOptions (function)", "defineConnector": "src/integration/connector.zod.ts#defineConnector (function)", "isConnectorUpstreamUnavailable": "src/integration/connector-provider-errors.ts#isConnectorUpstreamUnavailable (function)" diff --git a/packages/spec/test-typecheck-debt.json b/packages/spec/test-typecheck-debt.json index a83ef5a6d08..cd6ef88a9e7 100644 --- a/packages/spec/test-typecheck-debt.json +++ b/packages/spec/test-typecheck-debt.json @@ -142,11 +142,9 @@ "src/integration/connector.test.ts": { "TS6133: 'BasicAuthSchema' is declared but its value is never read.": 1, "TS6133: 'BearerAuthSchema' is declared but its value is never read.": 1, - "TS6133: 'ConnectorStatusSchema' is declared but its value is never read.": 1, "TS6133: 'ConnectorTypeSchema' is declared but its value is never read.": 1, "TS6133: 'NoAuthSchema' is declared but its value is never read.": 1, - "TS6133: 'SyncStrategySchema' is declared but its value is never read.": 1, - "TS6133: 'WebhookEventSchema' is declared but its value is never read.": 1 + "TS6133: 'SyncStrategySchema' is declared but its value is never read.": 1 }, "src/kernel/cluster.test.ts": { "TS6133: 'ServiceClusterScopeSchema' is declared but its value is never read.": 1, From a8544faa1650c6b24b8aa1493d6d011b4c981caf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:39:41 +0000 Subject: [PATCH 04/13] wip(spec): the ADR-0122 runtime case no longer expects the retired status default Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/src/type-alias-convention.pin.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 4bff0f600ba..56ab6cb88cd 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -2369,7 +2369,11 @@ describe('ADR-0122 type-alias convention', () => { type: 'saas', }); expect(parsedConnector.enabled).toBe(true); - expect(parsedConnector.status).toBe('inactive'); + // This line read `expect(parsedConnector.status).toBe('inactive')` until + // ADR-0049 retired `connector.status`: the key is a tombstone now and a + // parse no longer emits its default. `enabled` above is the surviving + // defaulted key this case needs — one default supplied by the parse. + expect(parsedConnector).not.toHaveProperty('status'); // And the flip's whole point, stated at runtime: the three keys above are // everything an author has to write, and the bare name is the type that From 288d0ed50ac15ffc529a5a20e7e68403c9769bc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:11:45 +0000 Subject: [PATCH 05/13] wip: changeset for the connector resilience retirement; the family pin no longer spells the sibling retirement's key Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- ...20273-connector-resilience-keys-retired.md | 84 +++++++++++++++++++ ...nnector-resilience-keys-retirement.test.ts | 10 +-- 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 .changeset/20273-connector-resilience-keys-retired.md diff --git a/.changeset/20273-connector-resilience-keys-retired.md b/.changeset/20273-connector-resilience-keys-retired.md new file mode 100644 index 00000000000..3e89ede4f66 --- /dev/null +++ b/.changeset/20273-connector-resilience-keys-retired.md @@ -0,0 +1,84 @@ +--- +'@objectstack/spec': minor +'@objectstack/connector-mcp': patch +'@objectstack/connector-openapi': patch +'@objectstack/connector-rest': patch +'@objectstack/connector-slack': patch +'@objectstack/service-automation': patch +--- + +feat(spec)!: retire the connector resilience family — `health` (health probe + circuit breaker), `status` and the nested `webhooks`, sixteen keys nothing read (#20273) + +**BREAKING** — `connector.health` (the `healthCheck` probe, eight keys, and the +`circuitBreaker`, six keys), `connector.status` and the connector-nested +`webhooks` are removed from `ConnectorSchema` and `DeclarativeConnectorEntrySchema` +— so from `defineConnector`, `stack.connectors[]`, the `PUT /api/v1/meta/connector/:name` +door and `AutomationEngine.registerConnector`. ADR-0049 enforce-or-remove, one +batch for the family, by the maintainer's criterion: does the mainstream platform +offer this capability? Author-configured health probes and circuit breakers are +not connector metadata in the mainstream (breakers live in API-gateway +infrastructure), and an authored status and a nested webhook list duplicate what +is already delivered here by other keys. + +Measured before removal, each against a lit control: zero reads of any of the +sixteen keys outside `packages/spec`. No loop ever polled a connector endpoint, +counted consecutive failures or tripped a breaker, and none of the four +`fallbackStrategy` behaviours existed. Nothing read an authored `status`: the +runtime's dispatchability answer is the COMPUTED `state` (`ready` / `degraded`) +on `GET /api/v1/automation/connectors`, which no authored value sets. A webhook +nested in a connector was never registered as a `webhook` item, so it was never +materialized into `sys_webhook` and never delivered. + +### FROM → TO + +| removed | what to write instead | +| --- | --- | +| `connector.health` (`healthCheck.*`, `circuitBreaker.*`, including `monitoringWindowMs` and the pre-rename `monitoringWindow`) | delete the block. Put health probes and circuit breaking in the connector provider or an upstream gateway. | +| `connector.status` | delete the key. `enabled: false` on a declarative entry is what withdraws a materialized instance or marks a catalog-only descriptor; whether a registered connector can be dispatched is the computed `state`. | +| `connector.webhooks` | delete the array. A webhook that is actually delivered is declared in the stack's top-level `webhooks:` collection — moving one there STARTS deliveries this connector never made, so decide per webhook. `events` and `signatureAlgorithm` have no counterpart there. | +| `ConnectorHealth`, `HealthCheckConfig`, `CircuitBreakerConfig`, `ConnectorStatus`, `WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm` (schemas, types, `…Parsed` types) | no replacement — nothing parsed or constructed them. | + +**The one-line fix: delete `health:`, `status:` and `webhooks:` from every connector.** +`os migrate meta --from 17` lists the mechanical edits for existing sources. + +⚠️ Runtime behaviour is deliberately **unchanged**: none of the sixteen keys ever +changed what a connector did. What changes is the answer an author gets — each +key is refused at parse with a prescription, and in `tsc` (its input type is +`never`), instead of being saved with no effect. + +### The retirement kit + +- **Tombstones.** `health`, `status` and `webhooks` are `retiredKey()` tombstones + on the private `ConnectorBaseSchema` both published carriers wrap (the schema + is not `.strict()`, so a bare deletion would be a silent strip, ADR-0104). + `RETIRED_KEYS_BY_MAJOR[18]`: `integration/Connector:{health,status,webhooks}` + and `integration/DeclarativeConnectorEntry:{health,status,webhooks}`. +- **Retired-default residue.** `status` was `.default('inactive')`, so every 17.x + parse emitted `status: 'inactive'` into every connector; that exact value joins + `connectionTimeoutMs: 30000` in the residue stage (accepted and stripped, so a + def a 17.x toolchain built still registers). Every other value is refused. +- **Seven defs leave whole** (`RETIRED_DEFS_BY_MAJOR[18]`): the four + `integration/` schemas and three enums listed above. +- **D2 conversion `connector-resilience-keys-removed`** (step 18, retired from + the load path): strips the three keys from `connectors[]` and from stored + `sys_metadata` connector rows (the rehydration seam replays it), one notice per + key, as a lossless delete. Nested webhooks are stripped, never moved. +- **The chain.** In the same step, `connector-health-and-trigger-durations-unit-in-key` + renamed `health.circuitBreaker.monitoringWindow` to `monitoringWindowMs`. That + breaker half is absorbed by this removal: the renamed key is itself removed, so + an author holding either spelling ends with no `health` block. The + conversion's `triggers[].interval` → `intervalSeconds` rename is unaffected. +- **D3 entry `connector-resilience-keys-retired`** carries the family's + judgement: which probe, breaker or nested webhook the author actually relied + on, and where it goes now. +- **Writers deleted.** The four shipped connector packages wrote + `status: 'active'` and the automation service's degraded husk wrote + `status: 'error'`; nothing read either back, and both writes are gone. +- **No deprecation window**, per the project's startup-stage posture. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` +is published, so this is breaking for consumers no telemetry was consulted for. + +Clause-②: no (narrowing) + + diff --git a/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts index 7927408040a..3716ed91e30 100644 --- a/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts +++ b/packages/spec/src/integration/connector-resilience-keys-retirement.test.ts @@ -170,13 +170,13 @@ describe('connector resilience family retirement — the tombstones', () => { ['base', ConnectorSchema], ['the /meta + stack.connectors carrier', DeclarativeConnectorEntrySchema], ] as const) { - // The shape a 17.x parse produced for a three-key author literal, - // including the other retired default already in the stage. - const r = schema.safeParse({ ...WELL_FORMED, status: 'inactive', connectionTimeoutMs: 30000 }); - expect(r.success, `${label} must accept the emitted defaults as residue`).toBe(true); + // The shape a 17.x parse produced for a three-key author literal. (Its + // other retired default is pinned by its own retirement's test, which + // also holds that key's spelling to that file alone.) + const r = schema.safeParse({ ...WELL_FORMED, status: 'inactive' }); + expect(r.success, `${label} must accept the emitted default as residue`).toBe(true); if (!r.success) continue; expect(r.data, `${label} must STRIP status`).not.toHaveProperty('status'); - expect(r.data, `${label} keeps stripping connectionTimeoutMs`).not.toHaveProperty('connectionTimeoutMs'); // CONTROL: the live sibling on the same shape is untouched by the stage. expect(r.data.enabled, `${label} keeps the live sibling`).toBe(true); } From 849fb6229e2552c24fd6145dc735ce3c84f21493 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 21:32:02 +0000 Subject: [PATCH 06/13] wip(service-automation): the degraded-husk test fixture no longer authors the retired status Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../service-automation/src/degraded-register-cause.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a3bc036454e..339ec4f62ec 100644 --- a/packages/services/service-automation/src/degraded-register-cause.test.ts +++ b/packages/services/service-automation/src/degraded-register-cause.test.ts @@ -95,7 +95,9 @@ function huskDef(name: string): Connector { name, label: name, type: 'api', - status: 'error', + // (`status: 'error'` stood here, mirroring the husk, until the spec key + // was retired — ADR-0049; the husk's degraded-ness is the registry's + // computed `state`.) enabled: true, authentication: { type: 'none' }, requestTimeoutMs: 30000, From 0d16977bbfa3a57231f08d7f906b3609c64e7eee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:09:23 +0000 Subject: [PATCH 07/13] wip(spec): the absorbed rename's registry entry and the timeout fixture note name the resilience removal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/src/conversions/registry.ts | 5 +++-- ...egration__CircuitBreakerConfig__monitoringWindow.ts | 10 ++++++++++ packages/spec/src/migrations/registry.ts | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index a380ec5ece6..694136a0721 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -9190,8 +9190,9 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = { 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. + // (`errorMapping`, `health` / `status` / `webhooks` — `health` once as + // `health.circuitBreaker.monitoringWindow` — and `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. diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts index 4e6b33bcfa5..ab2af9c412f 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__CircuitBreakerConfig__monitoringWindow.ts @@ -13,4 +13,14 @@ // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: // `connectors:` is a stack collection and a published connector row lands whole // in `sys_metadata`, so the chain has a seam that sees it. +// +// ⚠️ Superseded in the same unreleased step: the whole `health` block was then +// retired under ADR-0049 (the connector resilience family), so +// `integration/CircuitBreakerConfig` left whole (`RETIRED_DEFS_BY_MAJOR[18]`) and +// this tombstone left with it. The row STAYS — the whole-def removal steady +// state gate (b3) exempts — because it is still the record that the bare +// `monitoringWindow` spelling was retired. The rename's breaker half was absorbed +// by `connector-resilience-keys-removed`, which strips the block an author +// holding either spelling still carries; the `health` tombstone's prescription +// names both spellings. export const entry = 'integration/CircuitBreakerConfig:monitoringWindow'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ac9565e8b66..2326b10f8fd 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -16549,6 +16549,16 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // the D2 conversion `connector-health-and-trigger-durations-unit-in-key`: // `connectors:` is a stack collection and a published connector row lands whole // in `sys_metadata`, so the chain has a seam that sees it. + // + // ⚠️ Superseded in the same unreleased step: the whole `health` block was then + // retired under ADR-0049 (the connector resilience family), so + // `integration/CircuitBreakerConfig` left whole (`RETIRED_DEFS_BY_MAJOR[18]`) and + // this tombstone left with it. The row STAYS — the whole-def removal steady + // state gate (b3) exempts — because it is still the record that the bare + // `monitoringWindow` spelling was retired. The rename's breaker half was absorbed + // by `connector-resilience-keys-removed`, which strips the block an author + // holding either spelling still carries; the `health` tombstone's prescription + // names both spellings. 'integration/CircuitBreakerConfig:monitoringWindow', // ADR-0049 enforce-or-remove on `ConnectorSchema.connectionTimeoutMs` // (maintainer ruling 2026-09-22, letter A — the narrower SECOND decision this From 153f652c278a573f06f7b40cd19acdcaa307ba2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:02:01 +0000 Subject: [PATCH 08/13] chore(spec): regenerate the migration registry and liveness counts on the merged tree; reconcile the duration-rename D3 entry with the absorbed breaker half The rename family's D3 entry (landed from the D3-per-family census) still prescribed `monitoringWindow` -> `monitoringWindowMs`; that renamed key is itself retired with the whole `health` block, so the entry now prescribes the trigger rename only and sends the breaker spelling to the removal. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/liveness/state-counts.md | 4 +- ...nector-resilience-durations-unit-in-key.ts | 63 +++++++++++-------- packages/spec/src/migrations/registry.ts | 43 +++++++++++++ 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index c07e660689d..931c56a36eb 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -45,7 +45,7 @@ for both corollaries. | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | | `query` | 16 | 0 | 0 | 5 | 0 | 21 | | `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | -| `app` | 47 | 0 | 0 | 9 | 0 | 56 | +| `app` | 49 | 0 | 0 | 9 | 1 | 59 | | `book` | 20 | 0 | 0 | 1 | 0 | 21 | | `doc` | 15 | 0 | 0 | 0 | 0 | 15 | | `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **931** | **5** | **1** | **154** | **11** | **1102** | +| **total** | **933** | **5** | **1** | **154** | **12** | **1105** | diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts index 8dcf1c9b66e..9f423477a1d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts +++ b/packages/spec/src/migrations/entries/semantic/18.connector-resilience-durations-unit-in-key.ts @@ -6,33 +6,42 @@ import type { SemanticMigration } from '../../types.js'; // unit in its NAME) — the D3 entry of the // `connector-health-and-trigger-durations-unit-in-key` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). The -// two keys share one authored document and one conversion, so they share one -// entry. Both renamed keys are still unread (the liveness ledger records each -// as dead, `liveness/connector.json`): the rename is an honesty fix to the -// declaration, and the entry says so rather than implying a live engine. +// family was two keys in one authored document and one conversion: +// `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and +// `triggers[].interval` → `intervalSeconds`. +// +// ⚠️ Reconciled with the connector resilience retirement (ADR-0049, the same +// unreleased protocol step): the whole `health` block was then removed, so the +// breaker half of this rename was ABSORBED — the renamed key is itself retired, +// and the conversion now carries only the trigger half. This entry says so, +// rather than prescribing a rename to a key the parse refuses next; the +// removal's own judgement is the D3 entry `connector-resilience-keys-retired`. +// `triggers[].interval` is still unread (the liveness ledger records it dead, +// `liveness/connector.json`): the rename is an honesty fix to the declaration, +// and the entry says so rather than implying a live engine. export const entry: SemanticMigration = { id: 'connector-resilience-durations-unit-in-key', - surface: 'connector.health.circuitBreaker.monitoringWindow and connector.triggers[].interval — ' - + 'the two connector durations whose name carried no unit', - replacement: '`monitoringWindowMs` (milliseconds) and `intervalSeconds` (seconds) — rename each ' - + 'key; both values are unchanged.', - reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames both keys ' - + 'in `connectors[]` and on stored connector rows, keeping each value, with a separate notice ' - + 'per key so an operator sees which of its own keys moved; the rename is lossless because ' - + 'each key always meant the unit its new name states. Two judgments remain. First, the units ' - + 'were easy to get wrong in opposite directions: `monitoringWindow` (milliseconds) sat one ' - + 'key below `resetTimeoutMs`, and the bare token `interval` means MILLISECONDS elsewhere in ' - + 'this same spec while a trigger interval meant SECONDS — so a trigger written ' - + '`interval: 60000` for one minute asked for once every sixteen hours or so, and the rename ' - + 'keeps 60000. Second, neither key drives an engine today: no polling loop reads a trigger ' - + 'interval, and no circuit breaker exists for connectors, so nothing reads the monitoring ' - + 'window. An author who relied ' - + 'on either for behaviour has not been getting it, before or after this rename.', - acceptanceCriteria: 'No connector carries `health.circuitBreaker.monitoringWindow` or ' - + '`triggers[].interval`; the parse refuses both with the rename. Every `monitoringWindowMs` ' - + 'value is the window the author intends in milliseconds and every `intervalSeconds` value ' - + 'the cadence the author intends in seconds — a trigger meant to poll every minute reads ' - + '`intervalSeconds: 60`. No part of the deployment\'s design depends on a connector polling ' - + 'on that interval or tripping on that window: where it did, the author has moved that need ' - + 'to a mechanism that runs.', + surface: 'connector.triggers[].interval — the connector duration whose name carried no unit ' + + '(and, until the whole `health` block was retired, connector.health.circuitBreaker.monitoringWindow)', + replacement: '`intervalSeconds` (seconds) — rename the key; the value is unchanged. There is no ' + + 'replacement for `monitoringWindow`: its renamed spelling `monitoringWindowMs` was retired with ' + + 'the rest of `connector.health` — delete the block (see `connector-resilience-keys-retired`).', + reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames ' + + '`triggers[].interval` in `connectors[]` and on stored connector rows, keeping the value; the ' + + 'rename is lossless because the key always meant seconds. It used to rename the breaker\'s ' + + '`monitoringWindow` too, but that half was absorbed by `connector-resilience-keys-removed`, ' + + 'which strips the whole `health` block — so an author holding either `monitoringWindow` or ' + + '`monitoringWindowMs` ends with no key at all, and must not re-add `monitoringWindowMs`: the ' + + 'parse refuses the block. Two judgments remain for the trigger. First, the unit was easy to ' + + 'get wrong: the bare token `interval` means MILLISECONDS elsewhere in this same spec while a ' + + 'trigger interval meant SECONDS — so a trigger written `interval: 60000` for one minute asked ' + + 'for once every sixteen hours or so, and the rename keeps 60000. Second, the key drives no ' + + 'engine today: no polling loop reads a trigger interval, so an author who relied on it for ' + + 'behaviour has not been getting it, before or after this rename.', + acceptanceCriteria: 'No connector carries `triggers[].interval`; the parse refuses it with the ' + + 'rename, and every `intervalSeconds` value is the cadence the author intends in seconds — a ' + + 'trigger meant to poll every minute reads `intervalSeconds: 60`. No connector carries ' + + '`health` in any spelling (`monitoringWindow` or `monitoringWindowMs` included). No part of ' + + 'the deployment\'s design depends on a connector polling on that interval or tripping on a ' + + 'breaker window: where it did, the author has moved that need to a mechanism that runs.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 5bc8a43f4e0..84a54620277 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7187,6 +7187,49 @@ const step18: MigrationStep = { + 'deliberately do NOT move, and a sweep that removed either has over-applied this entry: ' + 'both resolve to real reads at the fetch site.', }, + // #15680 (stack card of #14478, maintainer ruling B: a duration key carries its + // unit in its NAME) — the D3 entry of the + // `connector-health-and-trigger-durations-unit-in-key` family (ruling B on + // #17152: one D3 entry per retirement family, even when D2 is lossless). The + // family was two keys in one authored document and one conversion: + // `health.circuitBreaker.monitoringWindow` → `monitoringWindowMs` and + // `triggers[].interval` → `intervalSeconds`. + // + // ⚠️ Reconciled with the connector resilience retirement (ADR-0049, the same + // unreleased protocol step): the whole `health` block was then removed, so the + // breaker half of this rename was ABSORBED — the renamed key is itself retired, + // and the conversion now carries only the trigger half. This entry says so, + // rather than prescribing a rename to a key the parse refuses next; the + // removal's own judgement is the D3 entry `connector-resilience-keys-retired`. + // `triggers[].interval` is still unread (the liveness ledger records it dead, + // `liveness/connector.json`): the rename is an honesty fix to the declaration, + // and the entry says so rather than implying a live engine. + { + id: 'connector-resilience-durations-unit-in-key', + surface: 'connector.triggers[].interval — the connector duration whose name carried no unit ' + + '(and, until the whole `health` block was retired, connector.health.circuitBreaker.monitoringWindow)', + replacement: '`intervalSeconds` (seconds) — rename the key; the value is unchanged. There is no ' + + 'replacement for `monitoringWindow`: its renamed spelling `monitoringWindowMs` was retired with ' + + 'the rest of `connector.health` — delete the block (see `connector-resilience-keys-retired`).', + reason: 'The D2 conversion `connector-health-and-trigger-durations-unit-in-key` renames ' + + '`triggers[].interval` in `connectors[]` and on stored connector rows, keeping the value; the ' + + 'rename is lossless because the key always meant seconds. It used to rename the breaker\'s ' + + '`monitoringWindow` too, but that half was absorbed by `connector-resilience-keys-removed`, ' + + 'which strips the whole `health` block — so an author holding either `monitoringWindow` or ' + + '`monitoringWindowMs` ends with no key at all, and must not re-add `monitoringWindowMs`: the ' + + 'parse refuses the block. Two judgments remain for the trigger. First, the unit was easy to ' + + 'get wrong: the bare token `interval` means MILLISECONDS elsewhere in this same spec while a ' + + 'trigger interval meant SECONDS — so a trigger written `interval: 60000` for one minute asked ' + + 'for once every sixteen hours or so, and the rename keeps 60000. Second, the key drives no ' + + 'engine today: no polling loop reads a trigger interval, so an author who relied on it for ' + + 'behaviour has not been getting it, before or after this rename.', + acceptanceCriteria: 'No connector carries `triggers[].interval`; the parse refuses it with the ' + + 'rename, and every `intervalSeconds` value is the cadence the author intends in seconds — a ' + + 'trigger meant to poll every minute reads `intervalSeconds: 60`. No connector carries ' + + '`health` in any spelling (`monitoringWindow` or `monitoringWindowMs` included). No part of ' + + 'the deployment\'s design depends on a connector polling on that interval or tripping on a ' + + 'breaker window: where it did, the author has moved that need to a mechanism that runs.', + }, // ADR-0049 enforce-or-remove — the D3 entry of the connector resilience family: // `connector.health` (the `healthCheck` probe and the `circuitBreaker`), // `connector.status` and the connector-nested `webhooks`, sixteen authorable keys From 18cd52631750374a4657390a8e380021ce3b83ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:33:52 +0000 Subject: [PATCH 09/13] chore(spec): regenerate liveness state-counts on the merged tree Main moved the action and translation rows; the connector row keeps this branch's retirement (dead 30, total 60). Regenerated with gen:liveness-counts, never hand-merged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/liveness/state-counts.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 931c56a36eb..e1c97e9e21c 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -30,7 +30,7 @@ for both corollaries. | `object` | 50 | 0 | 0 | 0 | 1 | 51 | | `field` | 91 | 0 | 0 | 1 | 1 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | -| `action` | 44 | 0 | 0 | 3 | 2 | 49 | +| `action` | 46 | 0 | 0 | 3 | 0 | 49 | | `hook` | 19 | 0 | 0 | 3 | 0 | 22 | | `permission` | 36 | 0 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -52,7 +52,7 @@ for both corollaries. | `job` | 15 | 0 | 0 | 1 | 0 | 16 | | `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | | `seed` | 13 | 0 | 0 | 0 | 0 | 13 | -| `translation` | 22 | 0 | 0 | 0 | 2 | 24 | +| `translation` | 23 | 0 | 0 | 0 | 1 | 24 | | `validation` | 18 | 0 | 0 | 0 | 0 | 18 | | `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **933** | **5** | **1** | **154** | **12** | **1105** | +| **total** | **936** | **5** | **1** | **154** | **9** | **1105** | From 597e867f4d1c1575b22be23eb337ca84283cdad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:34:36 +0000 Subject: [PATCH 10/13] docs(spec): the undrilled-containers baseline narrates connector/webhooks as a row that has left Review nit on the retirement: the `_containers` prose still described `connector/webhooks` as a recorded row after the row was deleted. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- .../spec/scripts/liveness/undrilled-containers.baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/scripts/liveness/undrilled-containers.baseline.json b/packages/spec/scripts/liveness/undrilled-containers.baseline.json index 1eee4314b8e..4fcf3d64984 100644 --- a/packages/spec/scripts/liveness/undrilled-containers.baseline.json +++ b/packages/spec/scripts/liveness/undrilled-containers.baseline.json @@ -1,6 +1,6 @@ { "_note": "SHRINK-ONLY RATCHET (#4956). A liveness-ledger entry on a CONTAINER property carries ONE blanket verdict for the whole subtree beneath it. That is a legal granularity — inventing per-key verdicts without evidence would be worse — but it must be DECLARED, because silence is indistinguishable from having looked. `dashboard.widgets` asserted in prose that its widget keys were 'classified in the DashboardWidgetSchema subtree', no such subtree had ever existed, and the gate had no way to disagree — so `widgets[].responsive` rode straight through the #3896 inert-key sweep that removed both its sibling `widgets[].performance` and its literal namesake `view.responsive` (#4956; retired four days late in #4876 / PR #4995).", - "_containers": "`containers`: the child keys under these coordinates are classified NOWHERE — not in this ledger, not in another file, nowhere. Each row is a recorded, countable gap and a candidate for drilling. The gate prints the total on every run; `check:liveness --undrilled` prints the worklist. A row leaves by being DRILLED (add `children` with a status + evidence per key, the way `view.list` / `view.form` are drilled), never by being deleted for convenience — a row whose container has since been drilled FAILS the gate, so the debt cannot be overstated either. Adding a row is legitimate only when you have no per-key evidence to record, and it is deliberately a visible edit to a file named for the debt, because the alternative it replaced (a reassuring sentence in a `note`) cost nothing to write and could not be checked. ⚠️ DEPTH-TWO MIGRATION (#17424): 51 rows arrived at once when the walk learned to recurse. They are not new debt — every one of them was already riding on a blanket verdict, below a DRILLED container, where the one-level walk could not see it and therefore never counted it. Reading the jump as a regression is the wrong reading: the instrument's denominator grew to match the surface it always covered. Each of these rows is a `/.` coordinate, and each leaves the same way any other does — by being drilled, deferred, or by its property going away. [#18582] One row arrived with the `connector` ledger: `connector/webhooks`. It is a container whose blanket verdict is `dead` — a connector's nested webhook array never becomes a `webhook` metadata item, so the `sys_webhook` materializer never sees it (the ledger row carries the census). It is RECORDED rather than deferred because `WebhookConfigSchema` is `WebhookSchema.extend({ events, signatureAlgorithm })`, so the gate's key-set EQUALITY check would correctly refuse a `deferred` row pointing at the governed `webhook` type; and rather than drilled because 8 of its 21 child keys are the ADR-0010 protection envelope this gate auto-classifies `live` everywhere else, and the other 13 would each repeat one sentence — fabricated granularity over a container that is dead as a whole.", + "_containers": "`containers`: the child keys under these coordinates are classified NOWHERE — not in this ledger, not in another file, nowhere. Each row is a recorded, countable gap and a candidate for drilling. The gate prints the total on every run; `check:liveness --undrilled` prints the worklist. A row leaves by being DRILLED (add `children` with a status + evidence per key, the way `view.list` / `view.form` are drilled), never by being deleted for convenience — a row whose container has since been drilled FAILS the gate, so the debt cannot be overstated either. Adding a row is legitimate only when you have no per-key evidence to record, and it is deliberately a visible edit to a file named for the debt, because the alternative it replaced (a reassuring sentence in a `note`) cost nothing to write and could not be checked. ⚠️ DEPTH-TWO MIGRATION (#17424): 51 rows arrived at once when the walk learned to recurse. They are not new debt — every one of them was already riding on a blanket verdict, below a DRILLED container, where the one-level walk could not see it and therefore never counted it. Reading the jump as a regression is the wrong reading: the instrument's denominator grew to match the surface it always covered. Each of these rows is a `/.` coordinate, and each leaves the same way any other does — by being drilled, deferred, or by its property going away. [#18582] One row arrived with the `connector` ledger: `connector/webhooks`. It was a container whose blanket verdict was `dead` — a connector's nested webhook array never became a `webhook` metadata item, so the `sys_webhook` materializer never saw it. It was RECORDED rather than deferred because `WebhookConfigSchema` was `WebhookSchema.extend({ events, signatureAlgorithm })`, so the gate's key-set EQUALITY check would correctly have refused a `deferred` row pointing at the governed `webhook` type; and rather than drilled because 8 of its 21 child keys were the ADR-0010 protection envelope this gate auto-classifies `live` everywhere else, and the other 13 would each have repeated one sentence — fabricated granularity over a container that was dead as a whole. That row has since LEFT by the third route above, its property going away: `connector.webhooks` was retired under ADR-0049 and is now a `retiredKey()` tombstone rather than a container, so the gate reported the row stale and it was deleted (the `connector` ledger's `webhooks` row carries the census).", "_deferred": "`deferred`: containers whose subtree IS classified elsewhere — and the reference is RESOLVED, not believed. This is the same claim that caused #4956, which is exactly why it may only be made as DATA: the gate requires the target to exist (a governed type root, or a drilled `type/prop` coordinate) and its classified key set to EQUAL the container's child key set. A dangling target fails; so does a drifted one — equality rather than subset, because a container that grows a key its target never classifies is #4956 reappearing one level down. Without this list the file's own header would have been a false claim: these six cover 248 child keys, nearly half the population. A `to` target may itself be a dotted coordinate (`dashboard/widgets.chartConfig`) since the walk recurses; the resolution rule is unchanged.", "_excluded": "ADR-0010 framework overlay fields (`protection`, `_lock*`, `_provenance` — auto-classified live, they never consult the ledger) and container properties with NO ledger row at all (already reported UNCLASSIFIED by the forward pass).", "_issue": "https://github.com/objectstack-ai/objectstack/issues/4956", From 0c522e3fc10dc1c6d4e478fdc54f6c54bd593a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:09:51 +0000 Subject: [PATCH 11/13] chore(spec): regenerate liveness state-counts, strictness counts and the connector reference page on the merged tree Main moved the rest_api liveness row, the api/ strictness count and the connector page's front-matter description; each regenerated with its own generator (gen:liveness-counts, gen:strictness-ledger, gen:docs on a build of the merged tree), never hand-merged. The retirement's rows are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- content/docs/references/integration/connector.mdx | 2 +- docs/audits/2026-07-unknown-key-strictness-ledger.counts.md | 2 +- packages/spec/liveness/state-counts.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 96eba3d58f9..7e0d0ffda35 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -1,6 +1,6 @@ --- title: Connector -description: Connector protocol schemas +description: "Defines the standard connector specification for external system integration." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 6c009f3bdc3..8e8a5dfc3a7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 78 | -| `api/` | 432 | +| `api/` | 431 | | `identity/` | 32 | | `integration/` | 5 | | `kernel/` | 247 | diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index e1c97e9e21c..ff84293feaf 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -62,9 +62,9 @@ for both corollaries. | `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | -| `rest_api` | 12 | 0 | 0 | 14 | 0 | 26 | +| `rest_api` | 12 | 0 | 0 | 12 | 0 | 24 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **936** | **5** | **1** | **154** | **9** | **1105** | +| **total** | **936** | **5** | **1** | **152** | **9** | **1103** | From ca5d8f775d6f342baf419bca19390456f49a0932 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:01:45 +0000 Subject: [PATCH 12/13] chore(spec): regenerate liveness state-counts on the merged tree Main moved the qa row (and so the total); the connector row keeps this branch's retirement. Regenerated with gen:liveness-counts, never hand-merged; gen:migration-registry and gen:strictness-ledger produced no diff. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/liveness/state-counts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index ff84293feaf..e43c4ef82b5 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -56,7 +56,7 @@ for both corollaries. | `validation` | 18 | 0 | 0 | 0 | 0 | 18 | | `api` | 25 | 0 | 0 | 1 | 2 | 28 | | `capability` | 12 | 0 | 0 | 0 | 0 | 12 | -| `qa` | 4 | 0 | 0 | 5 | 0 | 9 | +| `qa` | 8 | 0 | 0 | 1 | 0 | 9 | | `manifest` | 23 | 0 | 1 | 15 | 0 | 39 | | `crud_endpoints` | 6 | 0 | 0 | 2 | 0 | 8 | | `metadata_endpoints` | 7 | 0 | 0 | 2 | 0 | 9 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **936** | **5** | **1** | **152** | **9** | **1103** | +| **total** | **940** | **5** | **1** | **148** | **9** | **1103** | From e54b124b82f4ad3fe88cc240807335028a5ffa49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:06:24 +0000 Subject: [PATCH 13/13] chore(spec): regenerate the liveness state counts on the merged tree Main's action.aria retirement and this branch's connector retirement each moved a row of state-counts.md; the merge took main's side, and this regeneration re-derives both rows from the merged ledger. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV --- packages/spec/liveness/state-counts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index e43c4ef82b5..f52c5835da5 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -30,7 +30,7 @@ for both corollaries. | `object` | 50 | 0 | 0 | 0 | 1 | 51 | | `field` | 91 | 0 | 0 | 1 | 1 | 93 | | `flow` | 34 | 0 | 0 | 6 | 0 | 40 | -| `action` | 46 | 0 | 0 | 3 | 0 | 49 | +| `action` | 45 | 0 | 0 | 4 | 0 | 49 | | `hook` | 19 | 0 | 0 | 3 | 0 | 22 | | `permission` | 36 | 0 | 0 | 6 | 0 | 42 | | `position` | 12 | 0 | 0 | 0 | 0 | 12 | @@ -67,4 +67,4 @@ for both corollaries. | `sharing_rule` | 16 | 0 | 0 | 0 | 1 | 17 | | `connector` | 29 | 0 | 0 | 30 | 1 | 60 | | `analytics_cube` | 17 | 0 | 0 | 10 | 0 | 27 | -| **total** | **940** | **5** | **1** | **148** | **9** | **1103** | +| **total** | **939** | **5** | **1** | **149** | **9** | **1103** |