Skip to content

Commit 1a8ba3f

Browse files
feat(spec)!: an authored connector URL holds no credential part; its retryConfig and actions refuse an undeclared key (#29573)
domain:skills#2 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — every connector entry that `skills/**`, the hand-written `content/docs/**`, `examples/**` and `packages/apps/**` author (each `defineConnector({…})` and `connectors: [{…}]` literal, scanned) already keeps its URLs credential-free and declares only `RetryConfigSchema` / `ConnectorActionSchema` keys inside `retryConfig` and `actions`, so nothing they teach changes; each refusal carries its own prescription. ## 维护者速读 - 改了什么:作者编写的连接器实例中,`providerConfig` 与 `auth` 下任何层级的 URL 若带 userinfo 或凭据类查询参数,在 defineStack、os validate/compile、构建产物加载与保存门处被拒(保存为 422 `INVALID_METADATA`,逐个 URL 指出位置,提示改用 `auth.credentialRef`);条目里的 `retryConfig` 与每个 `actions` 元素改为封闭结构,未声明的键按名拒绝(凭据类键给出 `auth.credentialRef` 的改法,action 上的 `method`/`path`/`url` 给出 ADR-0097 §5 的说明)(两个封闭结构仍为模块私有、不导出、不发布,与开放结构共用同一组字段实例);连接器读取对 `auth` 端点套用与 `providerConfig` 同一遍历、同一判据的凭据部分改写。另按维护者裁决:保存时显式给出的 `retryConfig`(`{}`、读取所得原样或任何对象)整体替换库中的 `retryConfig`,库中行的未声明重试键不再沿用,保存应答逐个点名被丢弃的键;省略 `retryConfig` 照旧移除整个容器。 - 为什么改:维护者裁决——连接器元数据在任何位置都不承载凭据;写入门与读取侧按同一遍历、同一判据判断 URL,读取改写的就是写入拒绝的;作者条目的嵌套结构与顶层一样封闭。`retryConfig` 的未声明键没有任何写入门接受、运行时也不读取(物化时用开放的 `RetryConfigSchema` 解析并丢弃),沿用它只会把库中明文留下。 - 风险与回滚:破坏性变更(spec minor,两条 ADR-0087 D3 条目):在连接器 URL 里写了凭据、或在 `retryConfig` / `actions` 里写了未声明键的代码与包,升级后在校验、构建产物加载与保存时被拒;库中已存的行不重判、照常运行,读取隐去凭据部分,原样保存保留库值(`retryConfig` 的未声明键除外:首次显式保存时被丢弃并在应答中点名;库中字节变了,该实例在下一次对账时按完全相同的策略重新物化一次);不做存量迁移。回滚即 revert 本 PR。 - 先看:`packages/spec/src/integration/connector.zod.ts`(refinement 中逐 URL 拒绝;`sharedFields` 让封闭嵌套结构与开放结构共用字段实例,及其 guidance)、`packages/spec/src/integration/connector-credential-redaction.ts`(读取隐去的位置;`undeclaredRetryConfigKeysOf` 把未声明重试键列入 `notCarriedForwardKeys`,即本轮裁决的落点)、`packages/spec/src/integration/connector-url-credentials.ts`(唯一遍历 `findConnectorUrlCredentials` 与读取位置映射)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/connector-meta-door-url-and-nested-keys.dogfood.test.ts`,预期 26 个用例全部通过。 - 需要你做的:无,席位落地(`retryConfig: {}` 的裁决已在本 PR 实现,见 PR 评论 6593804783)。 ## Seat notes Refreshed 2026-11-03T04:26Z. - **Takeover.** By the maintainer's order this PR and its landing passed to seat session `session_01QSeKYn5CmmdShCJifm6WYj` (comment 6593633433); this body is maintained by that seat from here on. - **Ruling, implemented.** The open question (does an explicit `retryConfig: {}` over a stored row clear the keys the read withholds) was ruled in comment 6593804783: an explicit `retryConfig` replaces the stored one whole. Its named premise was verified before the edit and holds (Acceptance notes); the ruling and the pin the earlier should-fix named are on this head. - **Contract review.** The two PASS records (comments 6590686226 and 6590693346) are on `577984fe21163e30c191ccced287a3e1a0ba9e31`. The merge of `origin/main` and the ruling's implementation owe a contract-review-tier review on the new head before landing. The remaining should-fix from those records (a depth specimen for the deleted-baseline pin) is unchanged; the items on the shared URL predicate stay with the maintainer, at rule level only here. - **Ruling record (URL refusal).** The ruling quoted under *Why* reached the previous lane as the maintainer's no-card order to session `session_01Q4EjjP1K353tLKWRMajLsE`; by design there is no public card for it. - **Landing.** Not armed; this PR stays draft until that review. Then it is readied and landed serially (`landing-operations.md` §D), per the takeover record. ## What changes **BREAKING** — two accept-set narrowings on an authored connector entry (`DeclarativeConnectorEntrySchema`), each with its changeset and its ADR-0087 D3 upgrade-guide entry. **1. An authored connector URL holds no credential part.** At every door that runs the entry's refinement — `defineStack({ connectors })`, `os validate` / `os compile`, the metadata plugin's artifact load at boot and on reload, and `PUT /api/v1/meta/connector/:name` — a URL under an instance's `providerConfig` (any key, any depth, array elements included) or `auth` (`tokenUrl`, `authorizationUrl`, `resource`) holding a userinfo or a credential query parameter with a value is refused: one issue per URL, located at the URL, naming the parts it holds (never the URL or its value) and prescribing `auth: { type, credentialRef }`. At the save door that is `422 INVALID_METADATA`, and nothing is stored. There is no rule per provider: a URL is recognised by its value. **One walk, both doors.** `connector-url-credentials.ts` holds the one definition of a credential part and the one traversal over an entry's URLs (`findConnectorUrlCredentials`). The write refinement refuses every URL it returns; the read redactor maps each to the position it can address (`connectorUrlCredentialPositionOf`: rewritten in place, or the enclosing array or dotted key withheld whole) — so the read rewrites or withholds exactly the URLs the write refuses, and the read now covers the `auth` endpoints as it covers `providerConfig`. A URL inside a header or environment map (`providerConfig.headers`, `defaultHeaders`, `transport.headers`, `transport.env`) is judged by neither: the read withholds those maps whole. **2. An authored connector entry's nested shapes are closed.** `retryConfig` and each `actions` element refuse a key they do not declare, in one `unrecognized_keys` issue located at the container (`retryConfig`, `actions.N`), at every door that parses the entry, `defineConnector()` included. A credential-shaped key (`secret`, `token`, `apiKey`, `password`, `credentialRef`, …) carries the entry's own prescription (`auth.credentialRef`); an execution binding on an action (`method`, `path`, `url`) carries ADR-0097 §5's (an action carries none). The open shapes (`ConnectorSchema`, `RetryConfigSchema`, `ConnectorActionSchema`) keep their posture, because the runtime parses a stored row's retry policy with the open `RetryConfigSchema` at materialization and a provider derives actions through the open action shape. **Twins stay private, sharing field instances.** The closed nested shapes are module-private and emit no def; nothing is added to the published surface. Each is built from the SAME field instances as its open counterpart (`sharedFields`), because the authorable-surface deletion gate judges a def's reachability by instance: with per-carrier instances the open `integration/RetryConfig` and `integration/ConnectorAction` were reached by no metadata root, so a bare deletion from the shared declaration would have been waived as unreachable — the build-schemas fixture failure on the first push of this PR was that. With shared instances the gate's derived-clone bridge reaches both open defs through the twins. The guidance census then pairs the open defs with the twins' `guidance`; that pairing is consulted only for a deleted baseline key the guidance names, and every key it names is one the open defs never declared. **Unchanged.** The runtime materializes a stored row as it is stored and never re-judges it by the entry's refinement, so a connector stored before these refusals keeps dispatching; a save of its served form keeps the stored values (the metadata protocol's carry-forward) except a stored undeclared `retryConfig` key (3. below), and an edit of a served URL stores exactly the edit. No stored-row migration is shipped. **3. An explicit `retryConfig` in a save replaces the stored one whole** (the maintainer's ruling, comment 6593804783). A save that sends `retryConfig` — the served form, `{}`, any object — does not carry a stored row's undeclared retry key forward, and its answer names each key it dropped (advisory rule `withheld-key-dropped`, path `connectors[0].retryConfig.secret` for a key `secret`). A save that leaves `retryConfig` out removes it, as before. An undeclared key in an `actions` element, a `retryConfig` stored as a non-object (withheld whole), and every other withheld position are carried forward as before. The mechanism is declarative: the connector read redactor names the undeclared keys of an object `retryConfig` in `notCarriedForwardKeys` — the contract the undeclared `auth` keys already use — and the metadata protocol's carry-forward reads it, so no protocol code changes. The `advisories` description on the save answer (`SaveMetaItemResponseSchema`) now names this case, and `content/docs/references/api/protocol.mdx` is regenerated from it. ### FROM → TO ```ts // before — parsed green { name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://billing.example.com/v1?api_key=…' }, retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3, secret: '…' }, auth: { type: 'none' } }, // after — the URL is credential-free, the key is a reference, the policy holds only declared keys { name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', providerConfig: { baseUrl: 'https://billing.example.com/v1' }, retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3 }, auth: { type: 'api-key', credentialRef: 'BILLING_API_KEY', paramName: 'api_key' } }, ``` Set the secret under the name `credentialRef` gives (an environment variable in the open tier, or Setup → Connector credentials). Upgrade-guide entries: `connector-entry-url-credential-refused`, `connector-entry-nested-undeclared-key-refused`. ## Why Connector metadata holds no credential at any position, and the save door stores the body it accepts (ADR-0005 appendix (c), ruling B). The maintainer's ruling for this change, verbatim: > 在 `DeclarativeConnectorEntrySchema` 的 refinement 里,用和脱敏器相同的遍历检查 `providerConfig` 各层 URL 和 `auth.*Url`:带 userinfo 密码或凭据类查询参数就拒绝,返回 422 `INVALID_METADATA`,指出具体位置,并提示改用 `auth.credentialRef`。不按 provider 分别写规则。 > > 读取侧把 `auth.*Url` 纳入同一个 URL 脱敏遍历。 > > 不做存量迁移(`os migrate meta --stored`)。 and, for the nested shapes, to close them unless an ADR deliberately keeps them open. None does: ADR-0097, ADR-0143, ADR-0023, ADR-0024, ADR-0104 and the strictness ledger's `integration/` row were read, and ADR-0097 §5 states that an action carries no execution binding. So they close with the entry, declared as breaking. For a save over a stored row, the maintainer answered the question this PR left open (comment 6593804783) with the option put first, verbatim: > 显式 {} 就清掉,带前提 (Recommended) recorded there in three parts: an explicit `retryConfig` (including `{}`) replaces the stored container whole and the stored row's withheld undeclared keys are not carried forward into it, omitting `retryConfig` keeps today's behaviour; the premise that the runtime reads no undeclared `retryConfig` key when it materializes a stored row is verified before the edit; and if it does not hold, the work stops rather than forcing the clear or keeping the old behaviour. ## Tests | Layer | File | What it pins | |---|---|---| | spec | `src/integration/connector-entry-url-credentials.test.ts` | every URL position refused, located, prescription named, value never echoed; a CONTROL accept beside each; inside a stack the issue sits under the entry; header and env maps left to their own rules; the open `ConnectorSchema` unchanged; the write door refuses exactly what the read rewrites, and at every depth the read withholds each refused URL at its addressable position | | spec | `src/integration/connector-entry-nested-keys.test.ts` | `retryConfig` and `actions` elements refuse undeclared keys by `code` + `path` + `keys`; a credential-shaped key in either carries the credential prescription and an action's `method` / `path` / `url` carries ADR-0097 §5's (prescription text pinned); an action's own `key` stays its machine name; `defineConnector()` refuses through a `ZodError` with the same issue; the open carriers drop, not refuse; the private twins declare exactly the open shapes' keys, as the same field instances | | spec | `src/integration/connector-credential-redaction.test.ts` | the read rewrites the userinfo out of `auth.tokenUrl` / `auth.authorizationUrl` and a credential parameter out of `auth.resource`, byte for byte otherwise; the undeclared keys of an object `retryConfig`, a cleared one included, are named in `notCarriedForwardKeys`; CONTROL: a non-object `retryConfig` and an `actions` element's key stay carried forward | | metadata-protocol | `src/metadata-redaction.connector-undeclared-keys.test.ts` | the served form, `retryConfig: {}` and an omitted `retryConfig` each leave no undeclared retry key and report the drop; the top-level and `actions` keys are still carried; the runtime authoring gate is never told the key will be restored | | dogfood | `test/connector-meta-door-url-and-nested-keys.dogfood.test.ts` (26) | the HTTP save door's `422` envelope at each position, a URL inside an inline `providerConfig.spec` document included (located at `providerConfig.spec.servers.0.url`), nothing stored, an existing row untouched; CONTROL saves — an inline document with credential-free URLs stored as written, instances that materialize and dispatch; member and admin item / layered / list reads of a stored `auth` endpoint; carry-forward of served forms and of an author's edit; `retryConfig: {}` over a stored row holding an undeclared retry key — `200`, the answer's `advisories` name `connectors[0].retryConfig.secret` and never its value, and the next read of the stored row holds `retryConfig: {}`, while the withheld header map on the same row stays at rest (CONTROL); the served form replaces too; CONTROL: leaving `retryConfig` out removes it as before, and a non-object `retryConfig` is still kept at rest by a save that leaves it out | **Forced test edits** (fixtures the narrowing makes refusable at the save door, now seeded the way such a row reaches a store, through the system context): `connector-meta-door-undeclared-keys-withheld.dogfood.test.ts` (the nested `retryConfig` / `actions` rows), `connector-meta-door-entry-and-url-hardening.dogfood.test.ts` (the carry-forward row; saved disabled first, so the pin now also measures that the runtime materializes the stored row as it is), `packages/spec/src/kernel/metadata-authoring-lint.test.ts` (its two pins walked `connectors[].retryConfig` as the remaining nested strip site; they now walk `apis[].objectParams`, and the connector fixture stays as a closed collection with its located refusal asserted), `packages/cli/test/package-union-readers.test.ts` (the package-union probe moves from `connectors[].retryConfig` to `apis[].objectParams`, assertions unchanged in kind), and `packages/spec/scripts/build-schemas-check-mode.deleted-baseline.test.ts` (the #17356 unregistered-kind-root fixture moves from `integration/RetryConfig`, now reached through the twin's derived-clone bridge rather than by identity, to `integration/DeclarativeConnectorEntry`, the connector root's own def, so its eager reading stays a root-graph hit). **Forced by the ruling** (pins that asserted the old keep-on-save reading of an undeclared retry key): the served-form `retryConfig` test in `connector-meta-door-undeclared-keys-withheld.dogfood.test.ts` now asserts the replacement; `metadata-redaction.connector-undeclared-keys.test.ts` expects the served-form save without the retry key and its no-drop CONTROL uses a row without one; the redactor's carried-forward CONTROL in `connector-credential-redaction.test.ts` no longer holds a retry key. ## Acceptance notes - **Userinfo scope.** The write refuses the whole userinfo — `user:password@` and the `token@` form several HTTP APIs read as a token — not only a password, because the read already withholds the whole userinfo and both doors share one definition. Every built-in provider reaches its upstream through `fetch`, which refuses any URL carrying a userinfo, so no configuration that could dispatch is refused by the wider reading. - **Inline OpenAPI documents.** A `providerConfig.spec` given as an inline document object is walked like any other value under `providerConfig` (the read already rewrote URLs inside one); a document whose example URL carries a credential-named query parameter with a value is refused. Packaging the document as a file path is unaffected. - **Header and environment maps** stay outside the write walk, as the read withholds them whole and the `@objectstack/lint` rule `connector-credential-literal` warns on a credential-shaped literal inside one. - **`defineConnector()`** closes the nested shapes but does not run the entry refinement (as for the other ADR-0097 refinements); the URL refusal meets the entry at `defineStack`, the CLI, the artifact load and the save door. - **The ruling's premise, read before the edit.** The runtime never consumes an undeclared `retryConfig` key. Instance materialization (`service-automation` `plugin.ts`) parses the stored policy with the open `RetryConfigSchema` and hands the provider only the parsed result; measured on `src` and again on the built `dist` the runtime loads, a policy carrying two undeclared keys parses successfully, the keys are absent from the parsed policy, and the parsed policy and the resolved fetch options (`connectorFetchOptions`, the one execution site) are identical with and without them. One raw read exists: the reconcile's change fingerprint (`connectorInstanceSignature`) serializes the stored `retryConfig` as written, so the save that first drops such a key changes the fingerprint and the next reconcile re-materializes the instance once, with the identical policy. No retry or dispatch behaviour reads the key. - **An omitted `retryConfig` now names the drop.** What a save that leaves `retryConfig` out stores is unchanged (the container is removed, withheld keys included); its answer now also carries one `withheld-key-dropped` advisory per undeclared key the stored container held, as an omitted `auth` already does for its undeclared keys. - **Merge of `origin/main`.** Merged at `0eb97fa47` (merge commit `9a0d87754`, a real merge, through `scripts/pm/os-regen-merge.sh`), no conflicts. Two files were auto-merged because both sides moved them — `connector-credential-redaction.ts` and its test, where `main` had added `notCarriedForwardKeys` for undeclared `auth` keys (#29456); both sides' hunks are in the merged file and that contract is what the ruling now uses. The one `merge=os-regen` path the branch changes, the strictness counts shard `integration.md`, was changed by the branch only, so the script kept its bytes; `check:strictness-ledger` reads it current after the merge. - **Strictness ledger.** `integration/` counts regenerated (4 → 6 sites: the two closed twins). ## Verification All local runs went through `scripts/pm/os-verify-lock.sh` (its `VERDICT command-exit` line is the reading), each exit code captured before any pipe. Final head: `7d0799474` (branch merged with `origin/main` at `0eb97fa47`). The readings below are this round's, on that head unless a row says otherwise; the previous round's readings on `577984fe` are superseded by them. CI on this head is its own reading. <details><summary>Suites and typechecks</summary> | Command | Head | Result | |---|---|---| | `pnpm --filter @objectstack/spec test` (the `local` project) | `7d0799474` | exit 0 · 805 files · 23864 passed, 1 todo | | `pnpm --filter @objectstack/metadata-protocol test` | `7d0799474` | exit 0 · 308 files passed, 4 skipped · 22272 passed, 26 skipped | | the three connector-door dogfood files (`url-and-nested-keys`, `undeclared-keys-withheld`, `entry-and-url-hardening`) | `7d0799474` | exit 0 · 3 files · 44 passed | | `pnpm --filter @objectstack/spec typecheck` · `@objectstack/metadata-protocol typecheck` · `@objectstack/dogfood typecheck` | `7d0799474` | exit 0 · exit 0 · exit 0 | | `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/build-schemas-check-mode.deleted-baseline.test.ts` | `7d0799474` | exit 0 · 1 file · 15 passed | | `pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=1 test/package-union-readers.test.ts` | `7d0799474` | exit 0 · 1 file · 7 passed | | `pnpm --filter @objectstack/dogfood test` (whole battery, no shard, no path) | `7d0799474` | exit 0 · 402 files passed, 3 skipped · 3336 passed, 28 skipped | The rest of the spec `repo` project and the cli suites are CI's reading on this head (`Test Core`): of their files, this PR's diff edits only the two run above. </details> <details><summary>Ablation (one run, the ruling's implementation committed first)</summary> One mutation through `scripts/ablation-replace.mjs` (anchor hit once, blob changed): the undeclared `retryConfig` keys stay withheld but are no longer named in `notCarriedForwardKeys` — the pre-ruling behaviour. `packages/spec` rebuilt (tsup), `scripts/ablation-dist-preflight.mjs` → the marker present in 4 built files, so the protocol and dogfood suites (which resolve `@objectstack/spec` through `dist/`) read the mutation. | Pin | Mutated | Restored | |---|---|---| | spec `connector-credential-redaction.test.ts` — the three `retryConfig` not-carried-forward cases | red (3) | green | | spec, same file — CONTROL: a non-object `retryConfig` stays carried forward; every other case | green (64) | green (67/67) | | metadata-protocol `metadata-redaction.connector-undeclared-keys.test.ts` — served form, `{}`, omission each report the drop; the gate is not told; the served-form save without the key | red (5) | green | | metadata-protocol, same file — CONTROL no-drop row, the author's word, removal, reorder, malformed | green (11) | green (16/16) | | dogfood — `retryConfig: {}` replaces whole (with its header-map CONTROL), the served form replaces, the second file's served-form `retryConfig` | red (3) | green | | dogfood — CONTROL: omission removes as before; a non-object `retryConfig` kept at rest; every other pin in the three files | green (41) | green (44/44) | Restore: `ablation-replace --restore`; `git hash-object` equals `git rev-parse HEAD:PATH`; `git diff HEAD` empty and whole-tree `git status --porcelain` empty. `packages/spec` rebuilt, then `ablation-dist-preflight … --absent` → the marker absent from all 592 built files. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on `7d0799474` (20 paths against merge base `0eb97fa47`) derived 108 commands; each ran with its exit recorded before any pipe, and `--ran` on the `command :: exit N` record → 108 derived, 108 run, 0 NOT-MEASURED (derived from the recorded codes), 0 UNRUN; all exit 0. `pnpm check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT MET: seven packages outside the dogfood build closure had no `dist/`); those seven were built and it was re-run: exit 0. `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date; `content/docs/references/api/protocol.mdx` was regenerated by `check:generated --fix` (`gen:docs`) for the `advisories` description edit and is the only generated file this round changes; `check:api-surface` reads the public API surface unchanged, `check:authorable-surface` exit 0, `check:strictness-ledger` current (14 shards). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; `pnpm lint` is CI's run.) - [x] 4. One branch off `main`, one pull request (a no-card order: no issue to reference). - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. (Local scope per the dispatch contract; the full battery is CI's.) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a8c7e7c commit 1a8ba3f

20 files changed

Lines changed: 1603 additions & 205 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an authored connector entry refuses a key `retryConfig` or an `actions` element does not declare
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-entry-nested-undeclared-key-refused -->
11+
12+
**BREAKING** accept-set narrowing on an authored connector entry (`DeclarativeConnectorEntrySchema`), at every door that parses it: `defineConnector()`, `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`.
13+
14+
- **Before.** The entry refused an undeclared top-level key, but its two nested shapes were plain objects: an undeclared key inside `retryConfig` or inside an `actions` element parsed green and meant nothing to the runtime.
15+
- **Now.** Each is refused by name, in one `unrecognized_keys` issue located at the container (`retryConfig`, `actions.N`) that lists every undeclared key; at the save door `422 INVALID_METADATA`, and nothing is stored. A credential-shaped key (`secret`, `token`, `apiKey`, `password`, `credentialRef`, …) meets the entry's own prescription (`auth.credentialRef`); an execution binding on an action (`method`, `path`, `url`) is refused with ADR-0097 §5's: an action carries none.
16+
- **Unchanged.** The published open shapes — `ConnectorSchema`, `RetryConfigSchema`, `ConnectorActionSchema` — keep their posture: the def a plugin hands to `registerConnector`, the actions a provider derives, and the retry policy the runtime parses at materialization. A row stored before this refusal keeps working: the runtime parses its policy with the open `RetryConfigSchema`, which drops the undeclared key, and the read withholds it.
17+
- **A save over such a row.** An explicit `retryConfig` replaces the stored one whole (the maintainer's ruling): a save that sends `retryConfig` — the served form, `{}`, any object — does not carry the stored row's undeclared retry key forward, and its answer names each key it dropped (advisory rule `withheld-key-dropped`). A save that leaves `retryConfig` out removes it, as before. An undeclared key in an `actions` element stays at rest through a save of the served form.
18+
19+
## FROM → TO
20+
21+
```ts
22+
// before — parsed green; `secret` was read by nothing
23+
retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3, secret: '…' },
24+
actions: [{ key: 'get_invoice', label: 'Get invoice', method: 'GET', path: '/invoices/{id}' }],
25+
26+
// after — refused at retryConfig and at actions.0; keep the declared keys only
27+
retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3 },
28+
actions: [{ key: 'get_invoice', label: 'Get invoice', effect: 'read' }],
29+
```
30+
31+
A credential moves to the secrets layer under the name `auth.credentialRef` gives; an execution binding has no home on an action (a provider-bound instance derives its actions from the upstream). The upgrade guide entry is `connector-entry-nested-undeclared-key-refused`.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an authored connector URL holds no credential part, and the connector read judges every `providerConfig` and `auth` URL by the same walk
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: narrowed
9+
10+
<!-- adr-0087: registered connector-entry-url-credential-refused -->
11+
12+
**BREAKING** accept-set narrowing on an authored connector instance (`DeclarativeConnectorEntrySchema`), at every door that runs its refinement: `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`.
13+
14+
- **Before.** A URL under `providerConfig` or `auth` parsed green whatever it held.
15+
- **Now.** A URL holding a userinfo (`user@` or `user:password@` before the host) or a credential query parameter with a value (`api_key`, `token`, `access_token`, `key`, `secret`, `signature` and the rest of the connector redactor's list, matched case-insensitively on the decoded name, plus the instance's own `auth.paramName`) is refused: one issue per URL, located at the URL (`providerConfig.baseUrl`, `providerConfig.transport.url`, `providerConfig.spec`, `auth.tokenUrl`, `auth.authorizationUrl`, `auth.resource`, and any other URL at any depth under `providerConfig`, array elements included), naming the parts it holds — never the URL — and prescribing `auth: { type, credentialRef }`; at the save door `422 INVALID_METADATA`, and nothing is stored. There is no rule per provider: a URL is recognised by its value.
16+
- **One judgement, one walk, both doors.** The write door and the read redactor run one walk over an entry's URLs with one definition of a credential part, so the door refuses exactly the URLs the read rewrites or withholds. A URL inside a header or environment map (`providerConfig.headers`, `defaultHeaders`, `transport.headers`, `transport.env`) is judged by neither: the read withholds those maps whole.
17+
- **Read.** The built-in `connector` redactor rewrites the credential parts out of every URL that walk returns, the `auth` endpoints (`tokenUrl`, `authorizationUrl`, `resource`) as well as `providerConfig`, the rest of each URL served byte for byte and the position named in `redactedKeys`.
18+
- **Unchanged.** `ConnectorSchema` — the def a plugin hands to `registerConnector` — keeps its posture. The runtime materializes a stored row as it is stored and never re-judges it, so a connector stored before this refusal keeps dispatching with its stored URL; a save of its served form keeps the stored URL (the metadata protocol's carry-forward), and an edit of the served URL stores exactly the edit.
19+
20+
## FROM → TO
21+
22+
```ts
23+
// before — parsed green; the secret lived in the URL
24+
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
25+
providerConfig: { baseUrl: 'https://billing.example.com/v1?api_key=…' },
26+
auth: { type: 'none' } },
27+
28+
// after — refused at providerConfig.baseUrl; the URL is credential-free and the key is a reference
29+
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
30+
providerConfig: { baseUrl: 'https://billing.example.com/v1' },
31+
auth: { type: 'api-key', credentialRef: 'BILLING_API_KEY', paramName: 'api_key' } },
32+
```
33+
34+
Set the secret under the name `credentialRef` gives (an environment variable in the open tier, or Setup → Connector credentials) and delete it from the URL. There is deliberately no automatic rewrite: the value in the URL may be the only copy of the secret. The upgrade guide entry is `connector-entry-url-credential-refused`.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2876,7 +2876,7 @@ Answer to a device registration: the device id the client keeps
28762876
| **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. |
28772877
| **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. |
28782878
| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `datasource` → its live connection pool). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. |
2879-
| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. The save also reports here what it did to the row at rest that the author did not write, on a draft save too: one `warning` with rule `withheld-key-dropped` for each stored position it dropped because the type's read redactor withholds it and marks it not carried forward (a connector's undeclared `auth` keys, which no door accepts and nothing reads). Its `path` names where the value sat at rest, in the same spelling (`connectors[0].auth.token`). |
2879+
| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. The save also reports here what it did to the row at rest that the author did not write, on a draft save too: one `warning` with rule `withheld-key-dropped` for each stored position it dropped because the type's read redactor withholds it and marks it not carried forward (a connector's undeclared `auth` keys and the undeclared keys of its `retryConfig`, which no door accepts and nothing reads). Its `path` names where the value sat at rest, in the same spelling (`connectors[0].auth.token`). |
28802880
| **message** | `string` | optional | |
28812881

28822882
### Nested Shape: `SaveMetaItemResponse.projectionApplied`

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `integration/` | 4 |
22+
| `integration/` | 6 |

‎packages/cli/test/package-union-readers.test.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ const app = { name: 'unr_app', label: 'Union', navigation: [{ id: 'nav_tickets',
9595
const ticketZh = { 'zh-CN': { objects: { unr_ticket: { label: '工单', pluralLabel: '工单', fields: { title: { label: '标题' } } } },
9696
apps: { unr_app: { label: '联合', navigation: { nav_tickets: { label: '工单' } } } } } };
9797
const noteZh = { 'zh-CN': { objects: { unr_note: { label: '笔记', fields: { name: { label: '名称' } } } } } };
98-
const conn = { name: 'unr_conn', label: 'Conn', type: 'api', retryConfig: { zzzUnionProbeKey: 1 } };
98+
const api = { name: 'unr_notes_api', path: '/unr/notes', method: 'GET', type: 'object_operation',
99+
objectParams: { object: 'unr_note', operation: 'find', zzzUnionProbeKey: 1 } };
99100
`;
100101

101102
const CONFIGS: Record<string, string> = {
@@ -119,13 +120,15 @@ export default composeStacks([svc, appStack], { manifest: 'preserve' });
119120
i18nOne: `${PIECES}
120121
export default defineStack({ manifest: appManifest, objects: [ticket, note], apps: [app], i18n: I18N, translations: [ticketZh, noteZh] } as any);
121122
`,
122-
// An undeclared connector key. \`strict: false\` keeps it past the producer's
123+
// An undeclared authoring key. \`strict: false\` keeps it past the producer's
123124
// parse, so the doors' own pre-parse walk is the only carrier into \`--json\`.
124-
// It sits one level down, in \`retryConfig\` (a strip-mode shape the walk still
125-
// visits): the connector entry's own root is closed, so a top-level key there
126-
// is refused by the artifact load rather than warned about.
125+
// It sits one level down, in an API endpoint's \`objectParams\` (a strip-mode
126+
// shape the walk still visits): every collection root is closed, so a
127+
// top-level key is refused by the artifact load rather than warned about. It
128+
// was a connector's \`retryConfig\` until the authored connector entry closed
129+
// its nested shapes too, after which the load refuses that key instead.
127130
keyTwo: `${PIECES}
128-
const svc = defineStack({ manifest: svcManifest, objects: [note], connectors: [conn] } as any, { strict: false });
131+
const svc = defineStack({ manifest: svcManifest, objects: [note], apis: [api] } as any, { strict: false });
129132
const appStack = defineStack({ manifest: appManifest, objects: [ticket], apps: [app] } as any);
130133
export default composeStacks([svc, appStack], { manifest: 'preserve' });
131134
`,
@@ -262,6 +265,6 @@ describe('#22238 — the undeclared-authoring-key walk reads the package union',
262265
it('`os validate --json` carries the finding in `warnings` for a two-package app', () => {
263266
const run = r.validateTwo;
264267
expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0);
265-
expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.retryConfig.zzzUnionProbeKey')]);
268+
expect(named(run)).toEqual([expect.stringContaining('apis.unr_notes_api.objectParams.zzzUnionProbeKey')]);
266269
});
267270
});

‎packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
//
33
// The write-path inverse of the connector read projection, for the keys a
44
// connector does not declare: a save of the served form keeps what the read
5-
// withheld (top level, `retryConfig`, and an `actions` element addressed by
6-
// its `key`), and a container the author removed is never minted back.
5+
// withheld at the top level and in an `actions` element addressed by its
6+
// `key`; a container the author removed is never minted back; and an
7+
// undeclared key of an object `retryConfig` is not carried forward — a save
8+
// that sends `retryConfig` replaces the stored one whole (ruling 6593804783).
79
//
810
// Every value below is an obviously-fake placeholder.
911

@@ -40,9 +42,9 @@ describe('carry-forward of the keys a connector does not declare', () => {
4042
expect(body.actions[0]).toEqual({ key: 'get_invoice', label: 'Get invoice' });
4143
});
4244

43-
it('a save of the served form keeps every withheld value, the edited label included', () => {
45+
it('a save of the served form keeps every carried-forward withheld value, the edited label included', () => {
4446
const out = carryForwardRedactedValues('connector', { ...served(), label: 'Vendor catalog (edited)' }, stored);
45-
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)' });
47+
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)', retryConfig: { strategy: 'exponential_backoff' } });
4648
});
4749

4850
it('an `actions` element is followed by its `key` when the author reorders the list', () => {
@@ -90,6 +92,35 @@ describe('carry-forward of the keys a connector does not declare', () => {
9092
});
9193
});
9294

95+
// [ruling 6593804783] An undeclared key of an object `retryConfig` is withheld
96+
// AND not carried forward: a save that sends `retryConfig` replaces the stored
97+
// container whole, and the drop is reported.
98+
describe('an undeclared `retryConfig` key is not carried forward', () => {
99+
it('a save of the served form drops it, names it, and still carries the top-level and `actions` values', () => {
100+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored);
101+
expect(item).toEqual({ ...stored, retryConfig: { strategy: 'exponential_backoff' } });
102+
expect(dropped).toEqual(['retryConfig.secret']);
103+
});
104+
105+
it('`retryConfig: {}` replaces the stored container whole', () => {
106+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', { ...served(), retryConfig: {} }, stored);
107+
expect((item as Record<string, any>).retryConfig).toEqual({});
108+
expect(dropped).toEqual(['retryConfig.secret']);
109+
});
110+
111+
it('a body that removes `retryConfig` removes it, as before, and says so', () => {
112+
const body = served();
113+
delete body.retryConfig;
114+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, stored);
115+
expect(item).not.toHaveProperty('retryConfig');
116+
expect(dropped).toEqual(['retryConfig.secret']);
117+
});
118+
119+
it('the runtime gate is never told it will be restored', () => {
120+
expect(redactedPathsCarriedForward('connector', served(), stored).sort()).toEqual(['actions.0.token', 'apiKey']);
121+
});
122+
});
123+
93124
// [#28640 ②B] An undeclared `auth` key is withheld AND not carried forward: a
94125
// save of the served form drops it, and the drop is reported.
95126
describe('an undeclared `auth` key is not carried forward', () => {
@@ -136,8 +167,10 @@ describe('an undeclared `auth` key is not carried forward', () => {
136167
expect(dropped).toEqual(['auth.token']);
137168
});
138169

139-
it('CONTROL: a row with no undeclared `auth` key reports no drop', () => {
140-
const { dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored);
170+
it('CONTROL: a row with no undeclared `auth` or `retryConfig` key reports no drop', () => {
171+
const atRest = { ...stored, retryConfig: { strategy: 'exponential_backoff' } };
172+
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', atRest)));
173+
const { dropped } = carryForwardRedactedValuesReportingDrops('connector', body, atRest);
141174
expect(dropped).toEqual([]);
142175
});
143176
});

0 commit comments

Comments
 (0)