Skip to content

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 - #28688

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/harden-connector-undeclared-keys
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/harden-connector-undeclared-keys

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — every connector entry skills/**, content/docs/**, examples/** and packages/apps/** author (each defineConnector({…}) and connectors: [{…}] literal, scanned) already uses only declared entry keys, so nothing they teach changes; the new refusal carries its own prescription.

维护者速读

  • 改了什么:作者编写的连接器条目(connectors: / PUT /meta/connector/:name)改为封闭结构,未声明的顶层键在 defineStack、os validate/compile、构建产物加载与保存时按键名拒绝(保存为 422);连接器的元数据读取额外隐去 providerConfig 下 URL 的 userinfo 与凭据类 query 参数,以及非对象形态的 auth / authentication。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:在连接器条目顶层多写了键的元数据会被拒(错误信息给出改法:凭据走 auth.credentialRef,provider 配置挪进 providerConfig);插件 registerConnector 的运行时形状不变。读出的 URL 去掉凭据部分后原样保存,存储值保留、运行时照常使用。回滚即 revert 本 PR。
  • 先看:packages/spec/src/integration/connector.zod.ts(DeclarativeConnectorEntryBaseSchema 与 connectorBaseShape)、packages/spec/src/integration/connector-credential-redaction.ts(credentialPathsOf 的非对象分支与 credentialUrlRewritesOf)、packages/spec/src/kernel/metadata-authoring-lint.test.ts(最后一个开放根关闭,下限改为 0)。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts,预期 9 个用例全部通过。
  • 需要你做的:无,席位落地

What changes

Write door. An authored connector entry (DeclarativeConnectorEntrySchema) is closed. It refines a dedicated strictObject carrier, DeclarativeConnectorEntryBaseSchema, built from the same shape declaration as the open base (connectorBaseShape(), called once per carrier so each has its own field instances and the guidance census never pairs the open Connector def with the closed declaration). Every door that parses the entry refuses a top-level key the shape does not declare, by name, with a located unrecognized_keys issue: defineStack({ connectors }), os validate / os compile, the metadata plugin's artifact load at boot and reload, and PUT /api/v1/meta/connector/:name, where the answer is 422 INVALID_METADATA. Two curated families ride the refusal: credential-shaped keys point at auth.credentialRef, and provider settings written at the top level (baseUrl, spec, transport, headers, …) point at providerConfig; a few near-miss spellings (config, timeout, retry) are aliases to the declared key. The ADR-0010 protection envelope stays declared, so a package-stamped entry still parses. The refusal is terminal, so a body with an undeclared key meets that one refusal and not the ADR-0097 refinements as well.

ConnectorSchema, the def a plugin hands to registerConnector and the shape defineConnector() parses, keeps its posture; a control pin says so.

Read redaction. The built-in connector redactor withholds two more positions:

  • every URL at an object depth of providerConfig (a string starting with scheme://) is served without its userinfo and without its credential query parameters (the datasource names plus the HTTP conventions such as api_key, access_token, token, key, sig, and the auth.paramName an api-key instance names), the rest of the URL byte for byte. That covers baseUrl, transport.url and a URL spec. The query half goes through the same redactUrlCredentialQueryParams the datasource redactor uses, which now takes an optional name list (default and existing callers unchanged).
  • an auth or authentication value that is not an object is withheld whole. Cleared forms (null, '') are served as written.

Each rewritten or withheld position is named in redactedKeys, so the protocol's carry-forward keeps the stored value on a save of the served form, and the runtime keeps reading the unredacted execution view.

ADR-0087. A D3 semantic entry, connector-entry-undeclared-key-refused, carries the prescription; there is no mechanical rewrite, because an undeclared key may be a provider setting whose home depends on the provider, or the only copy of a secret.

Ledgers. The strictness ledger's integration/ row is re-classed from a flat wire to mixed, with the reason and the governing texts read before the close (ADR-0097, ADR-0143, ADR-0104, the type-schema suite's #2657 fence; none keeps the authored entry open). The authoring lint's last open root was connectors: its coverage floor moves to 0, asserted deliberately with the departed root confirmed rejected by the parse, and its two cross-collection fixtures move to a nested strip site (connectors[].retryConfig).

Tests

  • packages/spec/src/integration/connector-entry-undeclared-keys.test.ts (new, 12 cases): the refusal for a scalar, a nested container and two keys on an instance and on a descriptor, pinned by code, path and keys, never echoing the value; both prescriptions; terminality; the stamped envelope; a retired key keeping its own prescription and the retired-default residue; the stack door; and the open ConnectorSchema as a control.
  • packages/spec/src/integration/connector-credential-redaction.test.ts (+15 cases): non-object auth / authentication withheld whole, cleared forms served; userinfo, a bare user-part token, case-insensitive query parameters, transport.url, a URL spec, a relative spec left alone, the instance's own paramName, untouched non-credential URLs, arrays and dot-spelled keys off the walk, and a withheld map not walked twice.
  • packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts (new, 9 cases, showcase boot): WRITE refusals (422 INVALID_METADATA, the key named, nothing stored, 404 to a member); CONTROL (declared-keys-only instance saves, reaches ready, dispatches with the reference resolved); READ for a member and an administrator on the item, layered and list reads (both spellings), with the stored rows untouched; CARRY-FORWARD (a save of the served form keeps the stored URL whole, and dispatch sends it).
  • packages/spec/src/kernel/metadata-authoring-lint.test.ts: the graduation above.

Ablation, one leg per fix, each with on-disk mutation proof and restore proof (scripts/ablation-replace.mjs: anchor count, blob change, blob back to HEAD, empty git diff HEAD), and for the dogfood legs a spec rebuild with scripts/ablation-dist-preflight.mjs proving the marker reached dist/ and was gone after the restore rebuild:

Fix ablated Spec unit (src) Dogfood (dist)
entry back on the open base 7 of 12 red (every refusal; controls green) 4 of 9 red (3 WRITE; the list then serves the stored keys)
URL rewrites never applied 5 of 32 red (every URL rewrite case) 3 of 9 red (item and layered read, list, carry-forward)
non-object auth / authentication not withheld 4 of 32 red (the four non-object cases) 2 of 9 red (item and layered read, list)

A first combined dist leg is not counted: its URL-rewrite marker was constant-folded by the bundler and the preflight could not find it in dist/, so that leg was redone per fix with a string-literal marker.

Acceptance notes

  • The 422 envelope locates an unknown top-level key at the entry root (path: '') and names it in the message, as zod locates unrecognized_keys at the containing object on every closed type.
  • 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.
  • metadata-type-schemas.test.ts's two notes that described the connector as open are dated rather than deleted; the suite's fence (envelope only, no posture case) is unchanged.

Verification

Local runs on head 406ef4017 (merge of origin/main at 6b9c1ca99), each under the shared verify lock, verdict lines quoted:

Package suites (all green)
Command Result Lock verdict
pnpm --filter @objectstack/spec test 783 files, 23149 passed, 1 todo command-exit 0
pnpm --filter @objectstack/metadata-protocol test 291 passed, 4 skipped files; 21944 passed, 26 skipped command-exit 0
pnpm --filter @objectstack/metadata test 65 files, 918 passed command-exit 0
pnpm --filter @objectstack/service-automation test 251 files, 3185 passed command-exit 0
pnpm --filter @objectstack/objectql test 512 files, 9898 passed command-exit 0
pnpm --filter @objectstack/runtime test 503 files, 8171 passed, 19 skipped command-exit 0
pnpm --filter @objectstack/service-datasource test 67 files, 1125 passed command-exit 0 (first attempt 99, never acquired; second attempt ran)
pnpm --filter @objectstack/cli exec vitest run --project unit 363 files, 5162 passed command-exit 0 (the integration tier is declared to CI: the diff touches no spawn entry or integration file)
pnpm --filter @objectstack/dogfood typecheck tsc --noEmit clean command-exit 0
pnpm --filter @objectstack/dogfood exec vitest run test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts test/connector-meta-door-credential-hardening.dogfood.test.ts 2 files, 20 passed command-exit 0 (pre-merge head 14e25f5de; the full battery below reruns both on this head)

The connector packages (connector-rest, connector-openapi, connector-mcp) are not in the diff and consume ConnectorSchema, which does not move; their suites are left to CI.

Head is now 91b48bc4b, one commit after 406ef4017. That commit is test(cli): it moves the #22238 probe key in packages/cli/test/package-union-readers.test.ts to a nested strip site (retryConfig), because the connector root is now closed. CI Test Core (6/8) on 91b48bc4b ran that file: 7 passed, cli 137 files / 1718 passed.

  • pnpm --filter @objectstack/spec typecheck: NOT MEASURED locally. Six attempts on the contended verify lock all answered 99. CI TypeScript Type Check and Type Check · workspace are success on 91b48bc4b.
  • The full dogfood battery pnpm --filter @objectstack/dogfood test on 91b48bc4b (the ! title owes it): os-verify-lock VERDICT command-exit 0, Test Files 370 passed | 3 skipped (373), Tests 2990 passed | 28 skipped (3018). CI's Dogfood Regression Gate skips the dogfood legs on pull_request by design, so this local battery is the evidence.
  • CI check-runs on 91b48bc4b: 56 success, 9 skipped, 0 failure.

Derived gate families (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, merge base 004871f79): 85 derived, 84 run with exit 0, 1 NOT MEASURED (pnpm check:dual-build-cjs-loads, exit 3, PREREQUISITE NOT MET: seven packages outside this diff have no dist/ in this worktree); --ran reconciliation: 85 accounted, 0 unrun. pnpm --filter @objectstack/spec check:generated is among the 84 and is green after gen:strictness-ledger (the integration/ shard total moves 3 → 4). Every family CI runs is read from this PR's check-runs, not re-run here.


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 28 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/api/wire-format.mdx (via access_token (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/automation/connectors.mdx (via access_token (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES), api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES), providerConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema; a string literal in credentialUrlRewritesOf), /api/v1/automation/connectors (route, a path literal in acceptanceCriteria), /meta/connector/:name (route, a path literal in a comment on a changed line; a path literal in surface))
  • content/docs/automation/flows.mdx (via retryConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema))
  • content/docs/data-modeling/external-datasources.mdx (via providerConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema; a string literal in credentialUrlRewritesOf))
  • content/docs/data-modeling/field-type-decision-tree.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/data-modeling/field-types.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/data-modeling/odata-datasources.mdx (via providerConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema; a string literal in credentialUrlRewritesOf), requestTimeoutMs (literal, a string literal in DeclarativeConnectorEntryBaseSchema))
  • content/docs/kernel/cluster.mdx (via /api/v1/automation/connectors (route, a path literal in acceptanceCriteria), /meta/connector/:name (route, a path literal in a comment on a changed line; a path literal in surface))
  • content/docs/protocol/kernel/config-resolution.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/protocol/objectql/types.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/protocol/objectui/actions.mdx (via client_secret (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/protocol/objectui/concept.mdx (via api_key (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via providerConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema; a string literal in credentialUrlRewritesOf), /api/v1/automation/connectors (route, a path literal in acceptanceCriteria))
  • content/docs/releases/v17/17-0.mdx (via DeclarativeConnectorEntrySchema (symbol, a top-level const), auth_token (literal, a string literal in CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES))
  • content/docs/releases/v17/17-5.mdx (via requestTimeoutMs (literal, a string literal in DeclarativeConnectorEntryBaseSchema), retryConfig (literal, a string literal in DeclarativeConnectorEntryBaseSchema))
  • content/docs/releases/v17/17-6.mdx (via /meta/connector/:name (route, a path literal in a comment on a changed line; a path literal in surface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 168 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json da6f0ce35da60bf81e79890b227815a989f9b165 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 725d5decd4bdf004b382ebf4d3403440cefe0d17 — the merge of head 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6 into base da6f0ce35da60bf81e79890b227815a989f9b165, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 725d5decd4bdf004b382ebf4d3403440cefe0d17 && git checkout 725d5decd4bdf004b382ebf4d3403440cefe0d17
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin da6f0ce35da60bf81e79890b227815a989f9b165 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6 && git checkout -B drift-repro da6f0ce35da60bf81e79890b227815a989f9b165 && git merge --no-ff 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6

node scripts/docs-audit/affected-docs.mjs --json da6f0ce35da60bf81e79890b227815a989f9b165

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs da6f0ce35da60bf81e79890b227815a989f9b165 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…he connector entry root is closed

Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6 · 2026-11-01T15:54Z

Taken under references/contract-review.md (b): this head has waited a full triage round with no record, claim, hold or hand-off. The review runs at CONTRACT_REVIEW_TIER.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6
Local-runs: none
Scope: packages/spec/src/data/datasource-credential-redaction.ts, packages/spec/src/integration/connector-credential-redaction.ts, packages/spec/src/integration/connector.zod.ts, packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts, packages/spec/src/integration/connector-entry-undeclared-keys.test.ts, packages/spec/src/integration/connector-credential-redaction.test.ts, packages/spec/src/kernel/metadata-authoring-lint.test.ts, packages/spec/src/kernel/metadata-type-schemas.test.ts, packages/cli/test/package-union-readers.test.ts, packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts, docs/audits/2026-07-unknown-key-strictness-ledger.md, docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md + packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/connector-auth.zod.ts, packages/spec/src/data/driver/common.zod.ts, packages/spec/src/kernel/metadata-type-schemas.ts, packages/spec/src/kernel/metadata-authoring-lint.ts, packages/spec/src/kernel/metadata-read-decorations.ts, packages/spec/src/stack.zod.ts, packages/spec/scripts/build-schemas.ts, packages/spec/scripts/check-generated.ts, packages/spec/scripts/build-api-surface.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/metadata-redaction.ts, packages/metadata-protocol/src/metadata-diagnostics.ts, packages/metadata/src/plugin.ts, packages/runtime/src/domains/packages.ts, packages/connectors/connector-rest/src/rest-connector.ts, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs

Read at 2026-11-01T16:07Z from the fetched branch (git diff origin/main...91b48bc4b, thirteen files, 854 insertions, 41 deletions), the PR body and the head's check runs; nothing built or run. Face: a published schema (packages/spec/src/**) plus a declared narrowing, so the floor is every path outside the changeset. Per the 2026-10-29 disclosure ruling this record names positions and contracts only. A triage claim by session_01AavokzJ5DndAwitDXvKy4U sits on this head (2026-11-01T15:54Z); this is the lane seat's record, which the same-head rule (lane wins) makes the governing one — the triage seat withdraws its claim and discards its output.

① Derived judgments

  1. The closed carrier, and which doors refuse — right. connectorBaseShape() is a function returning a fresh shape per call; ConnectorBaseSchema = z.object(connectorBaseShape()) keeps the open posture behind ConnectorSchema and defineConnector, and DeclarativeConnectorEntryBaseSchema = strictObject({…}, connectorBaseShape()) is the closed twin that DeclarativeConnectorEntrySchema refines and then re-wraps in acceptRetiredDefaultResidue, the feat(spec): retired-defaulted-key tolerance — the retired default parses as inert residue and strips; non-default values keep the loud refusal (#12497 class rule) #12840 shape. strictObject is z.object(shape, { error }).strict() with the unknown-key refusal marked terminal (markUnknownKeyRefusalTerminal), so a body carrying an undeclared key raises exactly one unrecognized_keys issue, located at the entry (path: [], the containing object — identical to every closed type: ManifestSchema, ProtectionSchema, the four static auth kinds of feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions #28596) and the superRefine does not add to it (pinned, "the refusal is terminal"). The doors reached are the ones the changeset names: stack.zod.ts (connectors: z.array(DeclarativeConnectorEntrySchema)) carries it to defineStack, os validate, os compile, every other safeParse of ObjectStackDefinitionSchema (os migrate meta's stack parse among them) and Studio's client validation; UNREGISTERED_KIND_SCHEMAS.connector carries it to the save door's Zod gate (resolveOverlaySchema → safeParse → 422 INVALID_METADATA with the issue at path: '', the key named, never the value — dogfood WRITE pins it on all three families, and that nothing is stored); the metadata plugin's _parseAndRegisterArtifact parses ObjectStackDefinitionSchema at boot and reload, and compile.ts serializes the parsed stack, so only a hand-assembled artifact can meet the refusal there. The ADR-0010 envelope is in the shape via ...MetadataProtectionFields (pinned, stamped entry parses), and the read decorations _diagnostics / _draft are stripped by the save door before the gate (METADATA_READ_DECORATIONS, the decoration strip beside stripDerivedProvenance), so a Studio round trip of a served item meets no refusal for the platform's own keys — the same path every closed type already takes.
  2. No door that loads STORED rows throws — right. resolveOverlaySchema has exactly two readers: the save-door gate and _diagnostics (metadata-diagnostics.ts). Hydration registers the raw row; readFlattenedMetaItems lets audience decide only the serving decorations, so getMetaItemsForExecution neither parses nor redacts and the automation plugin materializes a legacy row as before; getMetaItemsPerPackage (package export) and installPackage (stores the caller's object) parse nothing. Two server-stated rewrites DO pass stored rows through saveMetaItem, whose Zod gate is skipped for no source or write face: migrateStoredMetadata (source: 'migrate-stored', only rows a conversion rewrote) and duplicatePackage (writeFace: 'package-duplicate', every row of the package). Each wraps the one call in a per-row try/catch: a legacy connector row carrying an undeclared top-level key now lands as outcome: 'failed' on the migration report, or in the copy response's failed[] with success: false, with the 422 prescription as report data — never a throw, and exactly what both passes already do for a view container with an unrecognized key (the measured example in the copy's own comment). Named in ③ (2) as a reason the stored-row migration is the resolving card. A legacy row with such a key is otherwise served as stored with _diagnostics.valid: false naming it; nothing gates on _diagnostics.valid. The changeset's "nothing in the runtime ever read an undeclared entry key" holds.
  3. Guidance and aliases — accurate. Every guidance key (apiKey, key, token, accessToken, secret, clientSecret, password, credentials, credentialRef, baseUrl, url, spec, transport, headers, defaultHeaders) and every alias key (config, providerOptions, timeout, timeoutMs, retry) is one the shape does NOT declare, and every alias target (providerConfig, requestTimeoutMs, retryConfig) is one it does — the alias-integrity audit's two halves, which walks the graph forcing every lazySchema. The credential prescription matches ADR-0097 §3 / ADR-0143 D3 (auth: { type, credentialRef }, the secrets layer); the provider prescription matches the three providers' providerConfig vocabularies. Retired keys stay retiredKey() tombstones in the shape, acceptsNothing keeps them out of the rename candidates, and a retired key still meets its own prescription with the residue tolerated (pinned). computeGuidanceRoutes() pairs a def to a declaration by shape-key signature AND field identity (shape[name] === prop), so the per-call shape factory is what keeps the open Connector def from being charged the closed declaration's promises — the shared/ row's finding, correctly applied; check:authorable-surface is green on this head.
  4. defineConnector open parse — a documented gap, not a defect of this change. defineConnector parses ConnectorSchema and so drops an undeclared key before a connectors: [defineConnector({…})] entry reaches the closed schema. Narrower than it reads: z.input on an object literal is excess-property-checked by tsc, so only a non-literal or an as any reaches the strip. The ledger row says so in as many words. See ③ (1).
  5. The redactor additions — right, every read exit inherits them. Items 4 and 5 live inside redactConnectorItem, which every exit resolves through getMetadataTypeRedactor (the feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions #28596 enumeration: protocol list/item/per-package reads, /layers, diffs, the generic data door over sys_metadata and its history, the runtime reader seam, MetadataManager.getPublished); the dogfood pins item, layered and both list spellings for a member and an administrator. Item 4: credentialPathsOf pushes ['auth'] / ['authentication'] whenever the value is not a plain record; the caller's readAt + isClearedForm skip an absent, null, '' or {} value, so a cleared form is served as written and an array, string or number is withheld whole (pinned, four cases). Item 5: credentialUrlRewritesOf walks providerConfig at object depth AFTER the removals (a withheld map is never walked — pinned), judges a string by scheme://, drops the WHOLE userinfo (URL_USERINFO_RE greedy to the last @ before /?#, the RFC 3986 boundary the datasource module documents; the user part goes too because https://TOKEN@host is a token form) and strips the credential query parameters through the datasource module's redactUrlCredentialQueryParams. Each rewrite's dotted path lands in redactedKeys; the carry-forward re-grafts the stored URL where the incoming value equals the served one (sameValue) and then re-runs the redactor over the grafted body, so a save of the served form keeps the stored URL and a save that writes the URL itself is the author's word (dogfood CARRY-FORWARD pins the round trip and that dispatch sends the stored URL). Arrays and dot-spelled keys are off the walk by design: the carry-forward's path grammar is dotted key hops, and a position it cannot address would turn a save of the served form into a silent credential deletion — the right call. Pure, input returned by reference when nothing is withheld, keys sorted: the registry contract holds.
  6. redactUrlCredentialQueryParams — default unchanged. The new params parameter defaults to CREDENTIAL_URL_QUERY_PARAM_NAMES; its only existing caller, redactUrlCredentials in the same module, passes nothing. CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES = the datasource set plus fifteen HTTP conventions, matched case-insensitively on the decoded name by the write door's own credentialQueryParamOf (an empty value is left in place, as documented). api-surface/data.json records export names only and api-surface-signatures.json hashes defineX factories only, so no baseline moves; check:api-surface is green.
  7. Served-shape narrowing on the read half — declared. The URL rewrites and the whole-position withholding narrow what /meta/connector serves; Metadata-protocol: narrowed covers it, as it did in feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions #28596.
  8. Generated artifacts — up to date. The strictness ledger's integration/ shard moves 3 → 4 (one new strictObject site in connector.zod.ts); check:strictness-ledger runs in the green Spec property liveness job, check:generated --reconcile-only and check:authorable-surface in the green Lint jobs. The ADR-0087 entry is discovered from entries/semantic/ by directory read; the changeset marker names its id; check-adr-0087-registration runs in the green Check Changeset job. json-schema manifest and authorable-surface record names and keys, neither posture — nothing to regenerate.
  9. Lint floor to 0, and the fixture moves — deliberate, and the tests still prove their claims. listLintableAuthoringCollections lists roots in strip mode; connectors was the last, so the empty set is the campaign finishing, asserted deliberately with the departed root confirmed unrecognized_keys by the parse and a control that parses. lintAuthoring's per-collection walk is explicitly NOT gated on the root's posture, RetryConfigSchema is a plain z.object, so connectors[].retryConfig is a live nested strip site and the two cross-collection fixtures and the cli: os lint translation coverage reads only a multi-package artifacts top level — every i18n/missing-*` goes silent once an app ships as packages (missed reader of the ADR-0130 2026-09-22 addendum; family of #22189) #22238 package-union probe keep proving the same properties (a finding from the second package reaches os validate --json; one voice per record; closed collections not contaminated). CI Test Core ran the cli file on this head.
  10. ConnectorSchema unchanged — pinned. The CONTROL case parses an undeclared key and strips it; the three connector packages consume this schema and are not in the diff.

② Semver level

@objectstack/spec: minor with the BREAKING banner, Clause-②: no (narrowing) at line start on the PR body and the changeset, Metadata-protocol: narrowed, a FROM → TO block with the upgrade instruction and no mechanical rewrite, and adr-0087: registered connector-entry-undeclared-key-refused resolving to entries/semantic/18.connector-entry-undeclared-key-refused.ts. check-changeset-no-major's coupling (a narrowed protocol line beside the (narrowing) arm, at least one moved package graded minor+) holds; the only moved published source is packages/spec/src/**. Precedents #28596 (minor, (narrowing), registered, a D3 entry) and #28496 declared the same way. Declaration correct.

③ Boundary flags

The PR author is objectstack-fleet[bot]; no author note is owed.

  • (1) defineConnector open parse (silent strip, ADR-0104). Follow-up, not blocking, not security-private. The strip drops the key before anything stores it, so no secret lands through this path; what is lost is a provider setting, silently, and only for a non-literal or as any input (tsc refuses the literal). Card: either parse the closed carrier in defineConnector (the factory is an authoring act, not registerConnector) or close ConnectorSchema itself — the ledger row names it as its own decision.
  • (2) Legacy rows with an undeclared top-level key, still served. Follow-up, security-private (filed from the private report, positions only). The write door now stops new rows and _diagnostics is the inventory, which is the posture feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions #28596 ③ (5) took for the same class one level down; nothing here widens the exposure. Of the options: the read-side withholding (every top-level key the entry shape does not declare, named in redactedKeys, so the carry-forward keeps it at rest) is the same fail-closed shape item 4 takes and is where the next change belongs, because it does not require per-row judgement; the stored-row migration remains the one that resolves it — and it is also what clears the per-row failed such a row now earns on a migrate-stored re-save or a package duplicate (① item 2); leaving it to the inventory alone is the weakest. Not this PR.
  • (3) Nested undeclared keys in retryConfig / actions[] (code-read only). Follow-up, not blocking. Both are plain z.objects, so under ADR-0005 ruling B a nested undeclared key parses green at the save door AND is stored and served as the body; the authoring lint warns at these sites, the parse strips them on the stack path. Same class as the root, one level down; actions[] reaches storage only on a descriptor (an instance refuses actions). A 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 nested-site row, and the read-side withholding in (2) would cover a credential-shaped one if written as a shape-driven walk.
  • (4) Write door accepts a URL-embedded credential; auth.tokenUrl / authorizationUrl not scrubbed. Follow-up, not blocking. The datasource precedent ([Decision] URL-embedded credentials (user:password@host in driver config.url) remain a live cleartext door after #7990 — refuse at publish, or accept as residual risk? #8082 / turso: ?authToken= in an authored config.url / config.syncUrl query string is credential material the #8082 userinfo refusal does not cover #8337) refuses at the write door; for a connector the URL lives in provider-owned providerConfig, so the refusal belongs to a shared URL check the entry's refinement runs over providerConfig at object depth (the same walk the redactor uses), not to each provider. Until then one consequence is worth the card's words: a served URL that lost its credential query parameter and is then EDITED by the author (a path change) is saved without the parameter — the URL is one position, so the carry-forward cannot keep half of it; the write-door refusal is what removes that ambiguity. auth.tokenUrl / authorizationUrl are declared keys (z.string().url()), so a userinfo there is served; extending the walk to the auth URL positions is a few lines and belongs with the same card.
  • (5) connector-rest buildUrl joins the path as text. Follow-up in connector-rest, not security, not blocking. new URL(base + suffix) appends the request path AFTER a base URL's query string, so a baseUrl carrying any query makes every path-bearing request malformed; the dogfood dispatches with an empty path for exactly that reason and says so. Pre-existing; a card for the provider.
  • (6) CI Dogfood Regression Gate skipped on pull_request. By design; the dev ran the full battery locally on this head and the two-minute file is named in the body. Not blocking; nothing to escalate.

④ Design judgment

  • packages/spec/src/integration/connector.zod.ts — holds against ADR-0097 (the refinements ride the closed base inside the residue stage, unchanged), ADR-0104 (tombstones kept, each still audible), ADR-0010 (envelope declared) and against its two consumers (stack.zod.ts, UNREGISTERED_KIND_SCHEMAS). The history sentence is true; surface: 'this connector entry' reads correctly in the message the dogfood pins. The per-call shape factory is the right answer to the one-declaration-two-doors trap and the docblock says why. No finding.
  • packages/spec/src/integration/connector-credential-redaction.ts — holds against the registry contract and the carry-forward's path grammar; the ordering (removals, then URL walk over the served form) is what keeps a withheld map off the walk, and the own-paramName list extension is read from the instance's auth only when it is a record of type: 'api-key'. Two should-fixes, both follow-ups already in ③ (2) and (4): the undeclared-top-level-key withholding, and the auth URL positions.
  • packages/spec/src/data/datasource-credential-redaction.ts — one optional parameter with the module's default; the two read paths now share one definition of where a query runs and what a pair is. Holds.
  • packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts — holds: the id is the changeset marker's, the four fields follow the sibling 17.connector-inline-authentication-publish-refused.ts, the prescription names declared homes (providerConfig per provider, auth.credentialRef, description / metadata for notes), the acceptance names a route that exists (GET /api/v1/automation/connectors, the ready state) and the _diagnostics inventory. Its first consumer is the upgrade guide; no rewrite is the right execution plan.
  • Ledger row and shard: the integration/ re-class to mixed records the measurement, the governing texts read, and the deliberate non-close of ConnectorSchema; consistent with the api/, kernel/ and shared/ rows' form. Holds.
  • The four spec test files, the cli test and the dogfood were read, not reviewed: every value is a placeholder, every refusal is pinned by code / path / keys with a control accept, every read assertion is an absence, and the stored rows are pinned untouched.

Must-fixes: none. Should-fixes (follow-up cards, none blocking): ③ (1) defineConnector parsing the closed carrier or closing ConnectorSchema; ③ (2) read-side withholding of undeclared top-level keys on a legacy row, security-private; ③ (3) the nested strip sites; ③ (4) the write-door URL-credential refusal over providerConfig and the auth URL positions; ③ (5) connector-rest buildUrl.

Implemented-by: claude/harden-connector-undeclared-keys
Reviewed-by: session_017xRBcVy4VXmkQjn2MGeaWk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:devx#1 · sunhaolin · session_017xRBcVy4VXmkQjn2MGeaWk

ACCEPT → head 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6 · 2026-11-01T16:08Z

维护者速读

  • 改了什么:声明式 connector 条目改为严格结构。顶层多写一个没声明的键,所有入口都会拒绝并点出键名:defineStack、os validate/compile、启动时加载 artifact、PUT /meta 返回 422。连接器读取时,providerConfig 里所有 URL 的 userinfo 和凭据类查询参数会被抹掉;非对象的 auth/authentication 整个隐藏。插件用的 ConnectorSchema 保持开放。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)。
  • 风险与回滚:BREAKING 收窄,spec 发 minor,ADR-0087 D3 条目 connector-entry-undeclared-key-refused。在 connector 顶层写了未声明键的包,构建和保存会被拒,按提示移到 providerConfig 或 auth.credentialRef。已存的数据照常加载。回滚即 revert 本 PR。
  • 先看:packages/spec/src/integration/connector.zod.ts(connectorBaseShape 与严格结构)和 packages/spec/src/integration/connector-credential-redaction.ts。
  • 两分钟验证:pnpm --filter @objectstack/dogfood exec vitest run test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts test/connector-meta-door-credential-hardening.dogfood.test.ts,预期 20/20 通过。
  • 需要你做的:无,席位落地。

席位意见:收。本 head 已过达档契约复核 6560432824(CONTRACT_REVIEW_TIER,PASS),没有必须修改的项。

席位核对

  1. 形态:draft,目标 main。按披露裁决不建卡,正文没有关闭关键词。正文由席位按 dev 的最终读数写入,首行是签名,Clause-②: no (narrowing) 和 Metadata-protocol: narrowed 都在。

  2. 前提实测:在 004871f79 上复现了三处问题(探针用完已删)。ablation 在单元层和 dist 层逐处去掉修复,都会变红;恢复后 hash 等于 HEAD,dist 中的标记也已清除。

  3. 门禁:本地派生共 86 项,跑了 85 项,exit 0;1 项 NOT MEASURED(dual-build,前置条件缺失)。完整 dogfood 全套 370 个文件、0 失败。本 head CI 共 65 项:56 success、9 skipped、0 失败;Lint & Repo Gates、TypeScript Type Check、Test Core、Check Changeset 都是 success。

  4. 契约面:触及已发布 schema,并声明了收窄 → 按 CONTRACT_REVIEW_TIER 做完整复核 ①–④,结果 PASS。复核确认三点:

  5. 范围外去向:复核的 should-fix 和 dev 的 open question 都是后续事项,不阻塞本 PR:

    • defineConnector 仍静默丢弃未声明键;
    • 旧数据中的顶层键读取时未隐藏;
    • 嵌套位置也会丢键;
    • 写入时仍接受 URL 中的凭据;
    • buildUrl 的拼接。

    其中凭据相关的几项按披露裁决由 PM 私下处理。

  6. 落地:不属于受管面,895 行,低于 3000 行上限 → pr_ready + automerge_enable。


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review claim withdrawn · 2026-11-01T16:27Z

⛔ Not a claim.

The lane's record 6560432824 is on the same head (91b48bc4), at CONTRACT_REVIEW_TIER. Under references/contract-review.md, when both claim one head, the lane wins. Triage therefore withdraws its claim (15:54Z) and posts no record on this head. The lane's record governs.

A security-class follow-up on the connector read path is handled outside GitHub under the 2026-10-29 disclosure ruling. It does not block this PR, because the gap is not introduced by this diff.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant