From 4185e6a9649addb653aed81d27561d031e7d1323 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:50:47 +0000 Subject: [PATCH 1/3] fix(spec)!: defineConnector parses the closed connector carrier defineConnector() parsed the open ConnectorSchema, so an undeclared top-level key was dropped before the closed entry behind defineStack could see it. It now parses the closed carrier behind the same retired-default residue stage, and refuses the key by name with the entry's message, aliases and guidance. ConnectorSchema keeps its open posture for the defs plugins hand to registerConnector. Claude-Session: https://claude.ai/code/session_01ADHkmT9tAeotFczB7jYquW Co-authored-by: Claude --- .../connector-entry-undeclared-keys.test.ts | 108 +++++++++++++++++- .../spec/src/integration/connector.zod.ts | 43 ++++++- 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts b/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts index 04666f1822c..a4c8fe91efa 100644 --- a/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts +++ b/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts @@ -8,11 +8,19 @@ // accept, so a refusal that fired on everything could not pass. The open // runtime carrier, `ConnectorSchema`, keeps its posture, pinned as a control. // +// `defineConnector()`, the helper every documented `connectors:` entry is +// wrapped in, parses the same closed carrier: it used to parse the open +// `ConnectorSchema`, which dropped the key before the closed entry behind +// `defineStack` could see it, so no door refused it. Its block below pins the +// refusal on the THROWN error, a control accept per case, and the residue and +// envelope it must keep accepting. +// // Every value below is an obviously-fake placeholder, and the refusal must // never echo it. import { describe, it, expect } from 'vitest'; -import { ConnectorSchema, DeclarativeConnectorEntrySchema } from './connector.zod'; +import { z } from 'zod'; +import { ConnectorSchema, DeclarativeConnectorEntrySchema, defineConnector } from './connector.zod'; import { ObjectStackDefinitionSchema } from '../stack.zod'; const PLACEHOLDER = 'TEST_PLACEHOLDER_NOT_A_SECRET'; @@ -118,3 +126,101 @@ describe('an authored connector entry refuses an undeclared top-level key', () = expect(parsed.data).not.toHaveProperty('vendorRegion'); }); }); + +/** The one issue `defineConnector` throws for `input`, as `[code, path, keys]`, and its message. */ +function helperRefusal(input: unknown): { located: [string, string, unknown]; message: string } { + let thrown: unknown; + try { + defineConnector(input as Parameters[0]); + } catch (error) { + thrown = error; + } + expect(thrown, 'defineConnector must refuse the entry').toBeInstanceOf(z.ZodError); + const { issues } = thrown as z.ZodError; + expect(issues, JSON.stringify(issues)).toHaveLength(1); + const issue = issues[0]!; + return { + located: [issue.code, issue.path.join('.'), (issue as { keys?: unknown }).keys], + message: issue.message, + }; +} + +describe('defineConnector() refuses an undeclared top-level key, as the entry does', () => { + it.each([ + ['a provider-bound instance', INSTANCE], + ['a catalog descriptor', DESCRIPTOR], + ] as const)('CONTROL: %s with only declared keys is returned parsed, defaults applied', (_label, entry) => { + const parsed = defineConnector(entry); + expect(parsed.name).toBe(entry.name); + expect(parsed.requestTimeoutMs).toBe(30000); + expect(parsed.authentication).toEqual({ type: 'none' }); + }); + + it.each([ + ['a credential-shaped scalar', { apiKey: PLACEHOLDER }, ['apiKey']], + ['a nested container', { extraConfig: { secret: PLACEHOLDER } }, ['extraConfig']], + ['two undeclared keys', { token: PLACEHOLDER, vendorRegion: 'eu' }, ['token', 'vendorRegion']], + ] as const)('%s: thrown at the root, naming each key, never the value', (_label, extra, keys) => { + for (const entry of [INSTANCE, DESCRIPTOR]) { + const { located, message } = helperRefusal({ ...entry, ...extra }); + expect(located).toEqual(['unrecognized_keys', '', keys]); + expect(message).toContain('this connector entry'); + for (const key of keys) expect(message).toContain(`\`${key}\``); + expect(message).not.toContain(PLACEHOLDER); + } + }); + + it('a misplaced `timeout` is named and pointed at `requestTimeoutMs`', () => { + const { located, message } = helperRefusal({ ...INSTANCE, timeout: 5000 }); + expect(located).toEqual(['unrecognized_keys', '', ['timeout']]); + expect(message).toContain('`requestTimeoutMs`'); + // CONTROL: the declared spelling of the same setting is accepted and kept. + expect(defineConnector({ ...INSTANCE, requestTimeoutMs: 5000 }).requestTimeoutMs).toBe(5000); + }); + + it('a credential-shaped key meets the credentialRef prescription', () => { + const { message } = helperRefusal({ ...INSTANCE, apiKey: PLACEHOLDER }); + expect(message).toContain('`auth: { type, credentialRef }`'); + }); + + it('a provider setting written at the top level is pointed at `providerConfig`', () => { + const { message } = helperRefusal({ ...INSTANCE, baseUrl: 'https://vendor.example.com' }); + expect(message).toContain('`providerConfig`'); + }); + + it('gives the verdict the stack door gives on the same entry', () => { + const entry = { ...INSTANCE, timeout: 5000 }; + const stack = ObjectStackDefinitionSchema.safeParse({ connectors: [entry] }); + expect(stack.success).toBe(false); + const stackIssue = stack.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + const { located, message } = helperRefusal(entry); + expect(located[2]).toEqual((stackIssue as { keys?: unknown }).keys); + expect(message).toBe(stackIssue.message); + }); + + it('a retired key keeps its own prescription, and a retired default stays accepted as residue', () => { + let thrown: unknown; + try { + defineConnector({ ...INSTANCE, triggers: [] } as unknown as Parameters[0]); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(z.ZodError); + expect((thrown as z.ZodError).issues.map((i) => i.code)).not.toContain('unrecognized_keys'); + const residue = defineConnector({ ...INSTANCE, status: 'inactive' } as unknown as Parameters[0]); + expect(residue).not.toHaveProperty('status'); + }); + + it('the ADR-0010 protection envelope is declared, so a stamped entry still parses', () => { + const parsed = defineConnector({ ...INSTANCE, _packageId: 'com.example.vendor', _lock: 'full' }); + expect(parsed._packageId).toBe('com.example.vendor'); + }); + + it('applies none of the stack-only refinements: an inline `authentication` is left to the stack door', () => { + // The helper closes the KEY set; the ADR-0097 cross-field rules stay where + // they bind, on the entry inside a stack, which still refuses this value. + const inline = { ...DESCRIPTOR, authentication: { type: 'bearer', token: PLACEHOLDER } } as const; + expect(defineConnector(inline).authentication).toEqual({ type: 'bearer', token: PLACEHOLDER }); + expect(DeclarativeConnectorEntrySchema.safeParse(inline).success).toBe(false); + }); +}); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index b279003bc2b..963b40ecb50 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1095,8 +1095,8 @@ const connectorBaseShape = () => ({ /** * Base Connector Schema — the OPEN carrier: core connector configuration shared * across all connector types, behind {@link ConnectorSchema} (the def a plugin - * hands to `registerConnector`, and `defineConnector`). Its posture is unchanged; - * the closed carrier for an AUTHORED entry is + * hands to `registerConnector`). Its posture is unchanged; the closed carrier + * for an AUTHORED entry — and the shape `defineConnector()` parses — is * {@link DeclarativeConnectorEntryBaseSchema} below. */ const ConnectorBaseSchema = lazySchema(() => z.object(connectorBaseShape())); @@ -1133,9 +1133,9 @@ const ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE = * parse), and every read exit serves that body. On an open shape an undeclared * key therefore parsed green and was kept, while `defineStack`, `os validate` * and `os compile` dropped it without a word. Closed, every door that parses - * this schema refuses it by name, located at the key: `defineStack({ connectors - * })`, `os validate` / `os compile`, the artifact load at boot and reload, and - * the save door with `422 INVALID_METADATA`. The ADR-0010 protection envelope + * this schema refuses it by name, located at the key: `defineConnector()`, + * `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load + * at boot and reload, and the save door with `422 INVALID_METADATA`. The ADR-0010 protection envelope * is declared in the shape, so a stamped, package-loaded entry still parses. * * The refusal is terminal ({@link strictObject}): a body with an undeclared key @@ -1198,13 +1198,44 @@ export type Connector = z.input; /** Post-parse shape of {@link Connector} — defaults applied, transforms run (ADR-0122). */ export type ConnectorParsed = z.infer; +/** + * The shape {@link defineConnector} parses: the CLOSED carrier + * {@link DeclarativeConnectorEntryBaseSchema} behind the same retired-default + * residue stage as {@link ConnectorSchema} and + * {@link DeclarativeConnectorEntrySchema}, so a retired key's emitted default + * stays accepted as inert residue here exactly as it is at those two. + * + * Closed because the helper's value is an AUTHORED entry: every documented and + * in-repo `defineConnector({ … })` is a `connectors:` stack entry, and the + * helper parsed first. On the open shape an undeclared top-level key was + * dropped here, so the closed entry behind `defineStack({ connectors })` never + * saw it and the author met no refusal at any door. Now the helper refuses it + * by name with the entry's own message, aliases and guidance. + * + * Not the full {@link DeclarativeConnectorEntrySchema}: its ADR-0097 / ADR-0143 + * cross-field rules (no inline `authentication`, `providerConfig` / `auth` + * need a `provider`, …) bind a connector authored inside a stack, and every + * stack door still applies them to the value this helper returns. Module + * private on purpose — the published carriers stay the two exports above. + */ +const DefineConnectorSchema = lazySchema(() => + acceptRetiredDefaultResidue(DeclarativeConnectorEntryBaseSchema, CONNECTOR_RETIRED_KEY_RESIDUE), +); + /** * Type-safe factory for an external-system connector. Validates at authoring time via * `.parse()` and accepts input-shape config (optional defaults, CEL * shorthand) — preferred over a bare `: Connector` literal. + * + * The parse is CLOSED: a top-level key the connector does not declare is + * refused by name, located at the key, with the prescription the authored + * entry gives (a credential-shaped key → `auth.credentialRef`, a provider + * setting → `providerConfig`, `timeout` → `requestTimeoutMs`). The def a plugin + * hands to `registerConnector` is parsed there by the open + * {@link ConnectorSchema}, whose posture this does not change. */ export function defineConnector(config: z.input): ConnectorParsed { - return ConnectorSchema.parse(config); + return DefineConnectorSchema.parse(config); } /** From f668513f330613aab507e25b8a13ad7fb87879e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:52:31 +0000 Subject: [PATCH 2/3] docs(spec): name defineConnector() in the connector-entry narrowing's ledger and changesets Claude-Session: https://claude.ai/code/session_01ADHkmT9tAeotFczB7jYquW Co-authored-by: Claude --- .changeset/28738-define-connector-closed.md | 28 +++++++++++++++++++ .../harden-connector-undeclared-keys.md | 2 +- .../2026-07-unknown-key-strictness-ledger.md | 2 +- ....connector-entry-undeclared-key-refused.ts | 9 +++--- 4 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 .changeset/28738-define-connector-closed.md diff --git a/.changeset/28738-define-connector-closed.md b/.changeset/28738-define-connector-closed.md new file mode 100644 index 00000000000..98b7e25e4b6 --- /dev/null +++ b/.changeset/28738-define-connector-closed.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: `defineConnector()` refuses a top-level key the connector does not declare, as every other connector door does + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing on `defineConnector()` (`@objectstack/spec` and `@objectstack/spec/integration`). + +- **Before.** The helper parsed the open `ConnectorSchema`, so a top-level key the connector does not declare was dropped without a word. Because the helper parses first, the closed authored entry behind `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load and the metadata save door never saw the key either: the one helper every documented `connectors:` entry is wrapped in was the one door that stayed silent. TypeScript's excess-property check catches the key only in an object literal, so the gap was reached through a spread, a variable or a cast. +- **Now.** The helper parses the closed connector carrier, behind the same retired-default residue stage as `ConnectorSchema`. Such a key throws a `ZodError` naming it (`unrecognized_keys` at the root), with the message the authored entry gives: a credential-shaped key (`apiKey`, `token`, `secret`, `password`, …) meets the `auth.credentialRef` prescription, a provider setting written at the top level (`baseUrl`, `spec`, `transport`, `headers`, …) is pointed at `providerConfig`, and `timeout` / `timeoutMs` are pointed at `requestTimeoutMs`. The ADR-0010 protection envelope stays declared and a retired key's emitted default (`status: 'inactive'`) stays accepted as residue. +- **Unchanged.** The return type (`ConnectorParsed`). `ConnectorSchema`, the def a plugin hands to `registerConnector`, keeps its open posture. The stack-only rules of an authored entry (no inline `authentication`, `providerConfig` / `auth` need a `provider`, no `actions` on a provider-bound instance) stay at the stack doors, which still apply them to the value the helper returns. + +## FROM → TO + +```ts +// before: returned without `timeout`; the request timeout stayed the 30 s default +defineConnector({ ...billingApi, timeout: 5000 }); + +// after: `timeout` is refused by name and pointed at the declared key +defineConnector({ ...billingApi, requestTimeoutMs: 5000 }); +``` + +Move a provider setting under `providerConfig`, put a secret into the secrets layer under the name `auth.credentialRef` gives, and delete any other undeclared key. The upgrade guide entry is `connector-entry-undeclared-key-refused`. diff --git a/.changeset/harden-connector-undeclared-keys.md b/.changeset/harden-connector-undeclared-keys.md index 9bc5cd4e886..d30568bf5c5 100644 --- a/.changeset/harden-connector-undeclared-keys.md +++ b/.changeset/harden-connector-undeclared-keys.md @@ -13,7 +13,7 @@ Metadata-protocol: narrowed - **Before.** The entry was a plain object: a top-level key it does not declare parsed green and meant nothing to the runtime. - **Now.** The entry is closed. Such a key is refused by name with a located error (`unrecognized_keys` at the entry; at the save door `422 INVALID_METADATA`). A credential-shaped key (`apiKey`, `token`, `secret`, `password`, …) meets the `auth.credentialRef` prescription, and a provider setting written at the top level (`baseUrl`, `spec`, `transport`, `headers`, …) is pointed at `providerConfig`. The ADR-0010 protection envelope stays declared, so a package-stamped entry still parses. An artifact `os build` wrote carries no undeclared key (it serializes the parsed stack), so only a hand-assembled artifact can meet the refusal at boot. -- **Unchanged.** `ConnectorSchema` — the def a plugin hands to `registerConnector`, and the shape `defineConnector()` parses — keeps its posture. Nothing in the runtime ever read an undeclared entry key, so no connector's behaviour moves. +- **Unchanged.** `ConnectorSchema` — the def a plugin hands to `registerConnector` — keeps its posture. Nothing in the runtime ever read an undeclared entry key, so no connector's behaviour moves. - **Read half.** The built-in `connector` redactor also withholds, on every read exit that serves a stored body through the metadata-type redactor registry: the userinfo and the credential query parameters of every URL under `providerConfig` (`baseUrl`, `transport.url`, a URL `spec`, and any other URL a provider keeps at object depth), the rest of each URL served byte for byte; and an `auth` or `authentication` value that is not an object, whole. A save of the served form keeps the stored value (the metadata protocol's carry-forward), and the runtime keeps reading the unredacted execution view. - `redactUrlCredentialQueryParams` (`@objectstack/spec/data`) takes an optional parameter-name list; its default, and every existing caller, is unchanged. diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 743810cd561..948ee886e31 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -1376,7 +1376,7 @@ rest at #5107. | `kernel/` | **mixed · `manifest.zod.ts` authorable, the rest wire** | ⚠️ **Split at #14192 — the flat `wire` verdict this row carried was right for the directory and wrong for one file in it, the `api/` row's finding one directory over.** `kernel/manifest.zod.ts` is `manifest:` — the hand-authored identity block of every `objectstack.config.ts`, every `os init` template, every example app, the Studio package form and the `objectstack.plugin.json` that `os plugin build` reads — and it was a plain open `z.object` under a cell that read "code-to-code". Measured first (2026-09-02 at `bd4aa4e49`, from `src` and through the built `dist`, identical readings): an unknown key parsed green and was DROPPED at all seven doors that reach `ManifestSchema` — the stack `manifest:` member (`defineStack` / `os validate` / `os compile`), `devPlugins[]`, the artifact `packages[]` entry in both its authoring and assembled forms, `os plugin build`, the `InstalledPackage` row schema, and the six package request/snapshot wire shapes that embed it. A transposed `namesapce` left `manifest.namespace` undefined at every one of them with exit 0 — and that key decides table names, REST paths and the install-time namespace gate (ADR-0129 D1–D2, ADR-0048 §3.2, ADR-0130 D1/D3). The same silent drop held one level down for `contributes` (`kind:` for `kinds:`), `contributes.kinds[]` and `engines` (`protocl:` for `protocol:`, which switches the load-time protocol handshake off). **The order the `api/` row records was kept: measurement, then the close.** No stored-row caller feeds this schema — `registry.validate('package' \| 'plugin')` has no live caller (`installPackage` stores the caller's object verbatim, `registerPlugin` is uncalled), the durable `sys_packages` row is re-registered through `registerApp` without a parse, and no bookkeeping key (`packageId` / `state` / `published*` / `package`) reaches any door — so the `peelStoredEnvelope` debt the `api/` row had to pay first does not exist here, and no bookkeeping key was taught to the authoring schema. Exactly ONE undeclared key was found at a real door, and it was DECLARED rather than curated: `main`, which `os plugin build` reads off the source `objectstack.plugin.json` to pick its entry and writes back into the compiled manifest as `dist/index.mjs`; three CLI fixtures author it, and a naked `strictObject` would have refused every plugin that names its entry — the shape this row would otherwise have recorded as refused. `ManifestSchema`, `contributes`, `contributes.kinds[]`, `engine` and `PluginEnginesSchema` are `strictObject` now: an undeclared key is a named rejection carrying the surface, the key and a rename (`namesapce` → `namespace`, `protocl` → `protocol`); the retired keys keep their `retiredKey()` tombstones (a tombstone types the key `never` and carries the upgrade text, which a bare refusal would not, and `acceptsNothing` keeps them out of the rename candidates); one curated wrong-layer pointer ships, `specVersion` (the retired CLI advisory axis — the live one is `engines.protocol`). `AssembledPackageBodySchema` (`stack.zod.ts`) inherits the closed posture through `.extend()`, so the load gate refuses an undeclared key on an assembled body too; that file's docblock sentence "NOT `strictObject`: `ManifestSchema` is an open object" is stale as of this change and is reported for its owner rather than edited here. Blast radius, measured with a strict preview before the close: 0 undeclared keys across the four example apps, the three `os init` templates, the `create-objectstack` blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest; the only shipped-block failure — `packages/cli/src/commands/create.ts`'s template manifest — fails the OPEN schema too (it lacks `id` and `type`) and is reported separately. Cloud is NOT MEASURED for this file (no checkout in the container, code search refused); if cloud parses a published `.osplugin` manifest through this schema, the compiled manifest it receives carries only declared keys (`main` and `integrity` included) by construction of `os plugin build`. The rest of `kernel/` is unchanged: plugin/kernel contracts, code-to-code | | `cloud/` | wire | multi-tenant runtime | | `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | -| `integration/` | **mixed · `connector.zod.ts`'s declarative entry authorable, the rest wire** | ⚠️ **Split at the connector-entry close — the flat `wire` verdict this row carried was right for the payloads and wrong for one shape, the `api/` and `kernel/` rows' finding again.** `DeclarativeConnectorEntrySchema` is the `connectors:` stack collection and the `connector` row the metadata save door keeps (`PUT /api/v1/meta/connector/:name`, bound in `UNREGISTERED_KIND_SCHEMAS`, which stores the request body for every type but `view` — ADR-0005 appendix (c), ruling B): an AUTHORED, STORED, SERVED type, not an upstream payload, yet it was a plain `z.object`, so an undeclared top-level key parsed green at every door that reads it. It is closed through a dedicated `strictObject` carrier (`DeclarativeConnectorEntryBaseSchema`) built from the same shape factory as the open one, each with its own field instances so `computeGuidanceRoutes()` never pairs the open `Connector` def with the closed declaration's `guidance`. Every door that parses the entry — `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load at boot and reload, and the save door (`422 INVALID_METADATA`) — refuses the key by name, located at it; two curated families ride the refusal: credential-shaped keys → `auth.credentialRef`, provider settings → `providerConfig`. The ADR-0010 envelope was already declared on the shape (commit b5404f496), so no stamped entry is refused. Governing texts read before the close: ADR-0097, ADR-0143, ADR-0104 and the type-schema suite's #2657 fence — none keeps the authored entry open; the `ConnectorSchema is NOT .strict()` sentences across `connector.zod.ts` state the fact that made each retirement a `retiredKey()` tombstone, not a decision to stay open. ⛔ Deliberately NOT closed: `ConnectorSchema`, the def a plugin hands to `registerConnector` (code-to-code — the wire half of this row) and the shape `defineConnector()` parses, so `defineConnector({ … })` still drops an undeclared key before the entry sees it; closing that carrier is its own decision. The rest of `integration/` is unchanged: connector payloads, upstream adds fields freely | +| `integration/` | **mixed · `connector.zod.ts`'s declarative entry authorable, the rest wire** | ⚠️ **Split at the connector-entry close — the flat `wire` verdict this row carried was right for the payloads and wrong for one shape, the `api/` and `kernel/` rows' finding again.** `DeclarativeConnectorEntrySchema` is the `connectors:` stack collection and the `connector` row the metadata save door keeps (`PUT /api/v1/meta/connector/:name`, bound in `UNREGISTERED_KIND_SCHEMAS`, which stores the request body for every type but `view` — ADR-0005 appendix (c), ruling B): an AUTHORED, STORED, SERVED type, not an upstream payload, yet it was a plain `z.object`, so an undeclared top-level key parsed green at every door that reads it. It is closed through a dedicated `strictObject` carrier (`DeclarativeConnectorEntryBaseSchema`) built from the same shape factory as the open one, each with its own field instances so `computeGuidanceRoutes()` never pairs the open `Connector` def with the closed declaration's `guidance`. Every door that parses the entry — `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load at boot and reload, and the save door (`422 INVALID_METADATA`) — refuses the key by name, located at it; two curated families ride the refusal: credential-shaped keys → `auth.credentialRef`, provider settings → `providerConfig`. The ADR-0010 envelope was already declared on the shape (commit b5404f496), so no stamped entry is refused. Governing texts read before the close: ADR-0097, ADR-0143, ADR-0104 and the type-schema suite's #2657 fence — none keeps the authored entry open; the `ConnectorSchema is NOT .strict()` sentences across `connector.zod.ts` state the fact that made each retirement a `retiredKey()` tombstone, not a decision to stay open. ⛔ Deliberately NOT closed: `ConnectorSchema`, the def a plugin hands to `registerConnector` (code-to-code — the wire half of this row); closing that carrier is its own decision. `defineConnector()`, the helper an authored entry is wrapped in, used to parse it too, so the helper dropped an undeclared key before the entry saw it; it now parses the closed carrier behind the same retired-default residue stage and refuses the key with the entry's own message. The rest of `integration/` is unchanged: connector payloads, upstream adds fields freely | | `identity/` | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified | | `shared/` | **mixed · `protection.zod.ts` and `http.zod.ts`'s rate-limit budget authorable, the rest as written** | ⚠️ **Annotated again at #18578 — a FOURTH instance of the same shape, and this one was invisible to a sweep that went looking for it.** `shared/http.zod.ts`'s `RateLimitConfigSchema` is a building block with two mounts, and this row's rationale — *strictness decided at the consuming schema* — held for exactly one of them: `system/stack-server.zod.ts` re-postured it closed, and it did so by building `strictObject(…, RateLimitConfigSchema.shape)` **from this shape object**, so ONE declaration answered for TWO emitted defs with OPPOSITE doors. `api/endpoint.zod.ts` mounts the same schema BARE on `apis[].rateLimit` — a registered metadata type since #5312, authored through `defineStack({ apis })`, the Studio form and `PUT /meta/api/:name` — where nothing re-postures it, so the two `guidance` entries (`keyBy`, `store`) prescribed to nobody and a misspelled budget was worse: `windowSeconds: 60` parsed green and metered the 60000 ms default, a thousandfold miss reported as success. ⚠️ **The sweep that should have caught it could not.** A `CONTRACT_REVIEW_TIER` review swept this exact class with live controls (5 `.strip()` sites, 0 `z.object(XSchema.shape…)`, 0 `z.object(bareIdentifier)`) and found nothing, because the sharing runs the other way round here: it hunted an OPEN clone built from a STRICT schema's shape, and this is the STRICT one built from the OPEN one's shape — every grep shape in that sweep is blind to that direction by construction. It was found instead by driving the gate's own instrument (`computeGuidanceRoutes()` in `scripts/build-schemas.ts`) over every emitted def: match each def to its declaration, then ask the def what it actually does with the key. Census on `42f8df1723`: 1527 emitted defs, 258 resolving to exactly one declaration, 779 keys promised, **770 delivered, 9 not** — 2 of them this live silent strip, the other 7 union defs whose discriminator the probe's one-key document cannot supply (they DO deliver to an author who writes a whole document). The close moves the strictness and both tables onto the shared schema, leaving ONE declaration and ONE door for both defs: 772 delivered, 7 not. ⚠️ The residual 7 are a PROBE boundary, ⛔ not a clean zero and ⛔ not a finding. ⚠️ **Annotated at #16845 — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** ⛔ **This directory is still UNTRIAGED and this is not a per-file row** — `shared/` has never had per-file rows, so `shared/protection.zod.ts` was **never enumerated** by this campaign; it was not deliberately accepted, because no verdict was ever taken on it. Two measurements say why the silence carried no information. **①** This row's own rationale — *strictness decided at the consuming schema* — is false for this file: all **16** `protection: ProtectionSchema` mounts across **14** files are the bare `ProtectionSchema.optional()`, not one `.extend()` / `.merge()` / `.omit()` / re-posture among them, so both the strictness and the MESSAGE are decided here in `shared/` and inherited verbatim by objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools and skills — very nearly every authorable metadata type in the platform. A row is read as licence, which is what this ledger's own gate exists to prevent. **②** Even a per-file row would not have surfaced the defect, and this is the part worth keeping: `ProtectionSchema` has been `.strict()` since it was introduced, so it counts in the `strict` column and could never carry a remaining-strip row. **This ratchet's axis is CLOSURE; the defect was MESSAGE QUALITY** — a closed shape with no error map, refusing `lockk` with zod's bare `Unrecognized key: "lockk"`: no surface, no declared-key list, no rename, on every one of those mounts. `strictObject` at #16845 (the `PluginPermissionsSchema` conversion at #16328 one directory over is the precedent). ⛔ The population of *closed shapes still carrying zod's bare message* is **NOT MEASURED** here and is not this row's claim — #14722's rule stands that a sweep needs its own card with its own measured count. The rest of `shared/` is unchanged: utilities and building blocks whose strictness really is decided at the consuming schema | | `qa/` | n/a | test fixtures | diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts b/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts index 568d5dfa01c..9eecc3808ca 100644 --- a/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts @@ -4,8 +4,8 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'connector-entry-undeclared-key-refused', - surface: 'connector entries AUTHORED through defineStack `connectors:`, `os validate` / `os compile`, the ' + - 'artifact load and `PUT /meta/connector/:name` — a top-level key the entry does not declare', + surface: 'connector entries AUTHORED through `defineConnector()`, defineStack `connectors:`, `os validate` / ' + + '`os compile`, the artifact load and `PUT /meta/connector/:name` — a top-level key the entry does not declare', replacement: 'keep only the keys `DeclarativeConnectorEntrySchema` declares. A provider setting moves under ' + '`providerConfig` (`baseUrl` for `rest`, `spec` / `baseUrl` for `openapi`, `transport` for `mcp`); a ' + 'credential goes into the secrets layer under the name `auth.credentialRef` gives (an environment variable ' + @@ -18,8 +18,9 @@ export const entry: SemanticMigration = { 'often misplaces: a credential and a provider setting. There is deliberately no mechanical rewrite: an ' + 'undeclared key may be a misplaced provider setting whose right home depends on the provider, or a secret ' + 'whose only copy is the value written there, and where either goes next is a judgement about the ' + - 'connector, not a rename. The runtime connector shape (`ConnectorSchema`, the def a plugin hands to ' + - '`registerConnector`) is unchanged. The metadata read also now withholds the credential parts of a URL ' + + 'connector, not a rename. `defineConnector()` refuses the key too: it parsed the open runtime shape, which ' + + 'dropped the key before any closed door saw it. The runtime connector shape (`ConnectorSchema`, the def a ' + + 'plugin hands to `registerConnector`) is unchanged. The metadata read also now withholds the credential parts of a URL ' + 'under `providerConfig` (userinfo and credential query parameters) and an `auth` or `authentication` ' + 'value that is not an object.', acceptanceCriteria: From bb3b9bc4d400421a7d6f81b1160c1de483bad2eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:43:52 +0000 Subject: [PATCH 3/3] docs(spec): name both published connector carriers in the defineConnector shape's docblock Claude-Session: https://claude.ai/code/session_01ADHkmT9tAeotFczB7jYquW Co-authored-by: Claude --- packages/spec/src/integration/connector.zod.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 963b40ecb50..9566b84066d 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1216,7 +1216,8 @@ export type ConnectorParsed = z.infer; * cross-field rules (no inline `authentication`, `providerConfig` / `auth` * need a `provider`, …) bind a connector authored inside a stack, and every * stack door still applies them to the value this helper returns. Module - * private on purpose — the published carriers stay the two exports above. + * private on purpose — the published carriers stay {@link ConnectorSchema} and + * {@link DeclarativeConnectorEntrySchema}. */ const DefineConnectorSchema = lazySchema(() => acceptRetiredDefaultResidue(DeclarativeConnectorEntryBaseSchema, CONNECTOR_RETIRED_KEY_RESIDUE),