Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/28738-define-connector-closed.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (already-registered connector-entry-undeclared-key-refused) the same narrowing as that entry, reached through one more door; its surface now names defineConnector() -->

**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`.
2 changes: 1 addition & 1 deletion .changeset/harden-connector-undeclared-keys.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
---
Expand All @@ -13,7 +13,7 @@

- **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.

Expand Down
2 changes: 1 addition & 1 deletion docs/audits/2026-07-unknown-key-strictness-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading