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
34 changes: 34 additions & 0 deletions .changeset/harden-connector-undeclared-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an authored connector entry refuses a top-level key it does not declare; the connector read withholds URL credential parts and a non-object `auth`

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-entry-undeclared-key-refused -->

**BREAKING** accept-set narrowing on an authored connector entry (`DeclarativeConnectorEntrySchema`), at every door that parses it: `defineStack({ connectors })`, `os validate` / `os compile`, the metadata plugin's artifact load at boot and on reload, and the metadata save door `PUT /api/v1/meta/connector/:name`.

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

## FROM → TO

```ts
// before — parsed green; neither key was read by anything
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
baseUrl: 'https://billing.example.com', apiKey: '…',
auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' } },

// after — refused by name; the provider setting moves under providerConfig, the secret moves out of metadata
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
providerConfig: { baseUrl: 'https://billing.example.com' },
auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' } },
```

Set the secret under the name `credentialRef` gives (an environment variable in the open tier, or Setup → Connector credentials) and delete the key. There is deliberately no automatic rewrite: an undeclared key may be a provider setting whose home depends on the provider, or the only copy of a secret. The upgrade guide entry is `connector-entry-undeclared-key-refused`.
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `integration/` | 3 |
| `integration/` | 4 |
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 @@ -1375,7 +1375,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/` | wire | 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) 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 |
| `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
7 changes: 5 additions & 2 deletions packages/cli/test/package-union-readers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const app = { name: 'unr_app', label: 'Union', navigation: [{ id: 'nav_tickets',
const ticketZh = { 'zh-CN': { objects: { unr_ticket: { label: '工单', pluralLabel: '工单', fields: { title: { label: '标题' } } } },
apps: { unr_app: { label: '联合', navigation: { nav_tickets: { label: '工单' } } } } } };
const noteZh = { 'zh-CN': { objects: { unr_note: { label: '笔记', fields: { name: { label: '名称' } } } } } };
const conn = { name: 'unr_conn', label: 'Conn', type: 'api', zzzUnionProbeKey: 1 };
const conn = { name: 'unr_conn', label: 'Conn', type: 'api', retryConfig: { zzzUnionProbeKey: 1 } };
`;

const CONFIGS: Record<string, string> = {
Expand All @@ -121,6 +121,9 @@ export default defineStack({ manifest: appManifest, objects: [ticket, note], app
`,
// An undeclared connector key. \`strict: false\` keeps it past the producer's
// parse, so the doors' own pre-parse walk is the only carrier into \`--json\`.
// It sits one level down, in \`retryConfig\` (a strip-mode shape the walk still
// visits): the connector entry's own root is closed, so a top-level key there
// is refused by the artifact load rather than warned about.
keyTwo: `${PIECES}
const svc = defineStack({ manifest: svcManifest, objects: [note], connectors: [conn] } as any, { strict: false });
const appStack = defineStack({ manifest: appManifest, objects: [ticket], apps: [app] } as any);
Expand Down Expand Up @@ -259,6 +262,6 @@ describe('#22238 — the undeclared-authoring-key walk reads the package union',
it('`os validate --json` carries the finding in `warnings` for a two-package app', () => {
const run = r.validateTwo;
expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0);
expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.zzzUnionProbeKey')]);
expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.retryConfig.zzzUnionProbeKey')]);
});
});
Loading
Loading