Skip to content

Commit 7b293c4

Browse files
feat(spec): a save of a connector's served form drops a leftover undeclared auth key and names it (#29456)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CDSh9Q8hhMV3sRgRxkeLrC)_ Part of #28640 Clause-②: yes (widening) Metadata-protocol: none Ruling-ref: 6566912252 Skills-docs: none — no authorable key, accepted value or metadata type changes; the change is a read redactor's result type and the save answer's advisories, which no skill teaches ## 维护者速读 - 改了什么:连接器旧数据里 `auth` 未声明键上的明文(如 `auth.token`),现在普通「读取 → 保存」一次就会从库里清掉,保存结果的 `advisories` 逐个点名被清掉的键。 - 为什么改:裁决 ②B(6566912252)。这些键门口早已拒收、运行时也从不读,带回去只是让明文一直留在库里。 - 风险与回滚:只影响连接器 `auth` 未声明键这一类;其他所有隐藏位置(请求头表、datasource 等)照旧带回。回滚即还原本 PR。 - 先看:`packages/metadata-protocol/src/metadata-redaction.ts`(`planCarryForward` 跳过不带回的位置)、`packages/spec/src/integration/connector-credential-redaction.ts`(标记)、`packages/spec/src/kernel/metadata-type-redaction.ts`(新成员与契约说明)。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.metadata-redaction.test.ts -t "28640"`,预期 6 条全绿。 - 需要你做的:批准新的公开名 `notCarriedForwardKeys` 与提示规则名 `withheld-key-dropped`(见 Acceptance notes),其余席位落地。 ## What changes - **`MetadataRedactionResult` gains `notCarriedForwardKeys?: string[]`** (`@objectstack/spec/kernel`). It names the positions among `redactedKeys` that are withheld AND not carried forward. It is a **subset** of `redactedKeys`, not a disjoint list: `redactedKeys` still names every withheld position, so every existing reader that asks "was anything withheld?" through `redactedKeys` (`redactStoredMetadataBody`, `redactMetadataItem`, `metadata-manager`'s served-document walk) withholds exactly what it did before. Optional, and absent means none, so the `datasource` built-in, the schema-derived redactors and every registered redactor (`service-automation`'s flow redactor included) keep carrying every withheld position forward with no edit. The interface docblock says what a read exit and what a save's carry-forward do with each list. - **The connector redactor marks its undeclared `auth` keys** (`redactConnectorItem`, item 1 of its module note). Each such key is named in both lists. A cleared value there (`null`, `''`, an empty map) is withheld too now, the way items 7 and 8 already withhold cleared undeclared keys: every instance-auth kind is a strict shape that refuses the key whatever it holds, so serving `auth.token: null` back made a plain read then save fail with `unrecognized_keys`. Every other withheld connector position (the four maps, `authentication`, credential URLs, undeclared top-level, `retryConfig` and `actions` keys, a non-object `auth`) stays carried forward. - **The carry-forward skips those positions and reports what it dropped** (`metadata-protocol`). `planCarryForward` never grafts a not-carried-forward path; it records each one the stored row holds and the incoming body does not hold. A value the incoming body itself holds there is the author's word, judged at the door, and not a drop. `carryForwardRedactedValues` and `redactedPathsCarriedForward` keep their signatures; a new package-internal `carryForwardRedactedValuesReportingDrops` returns the body and the dropped paths. `carryForwardRedactedCredentials` (the private save-door body) now answers `{ item, dropped }`. - **The save answer names each drop on `advisories`**, the existing channel a 2xx save carries its non-gating findings on (no second channel). One `warning` per dropped position: rule `withheld-key-dropped`, `where` = `connector "NAME"`, `path` = where the value sat at rest in the advisory spelling (`connectors[0].auth.token`), never the value. The key is still omitted when there is nothing to report. A draft save drains and reports too. `SaveMetaItemResponseSchema.advisories`'s description says so, and `content/docs/references/api/protocol.mdx` is regenerated from it. ## Why Ruling ②B (6566912252, the maintainer's 「同意」): "The redactor can mark a position "withheld and not carried forward"; the class covers undeclared keys under `auth` … A normal save of the served form therefore drops them instead of re-grafting them, and the save result names the dropped keys in its notices, so a drop at rest is loud, never silent." ## Tests New, all against rows written below the door (direct seed / stored body), as a pre-strict row would be: - `metadata-protocol/src/protocol.metadata-redaction.test.ts` › `[#28640 ②B] a save of the served connector form drains an undeclared auth key` (6): read serves without `auth.token` → save of the served form stores none and `advisories` names `connectors[0].auth.token` (value absent from the answer); CONTROL the header map beside it round-trips unchanged; the drain converges (second save: no `advisories` key); a draft save drains and reports; a cleared `auth.token: null` row now saves; CONTROL a datasource row's carry-forward is unchanged and its answer carries no `advisories`. - `metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts` › `an undeclared auth key is not carried forward` (5): drop + report while the undeclared root key and the header map are still carried; the runtime gate's `redactedPathsCarriedForward` no longer names it; the author's own value at that position (a switched `auth` kind that declares the key) is not a drop; removing `auth` drops and reports; CONTROL no drop on a row without one. - `spec/src/integration/connector-credential-redaction.test.ts` › `withheld and not carried forward` (8): both lists name every undeclared `auth` key; unknown `type`; the three cleared forms are withheld and the served form then passes `DeclarativeConnectorEntrySchema` (it fails before redaction); CONTROL every other withheld position leaves the member absent; CONTROL a non-object `auth` is still carried; only the `auth` key is marked beside the other positions. - `spec/src/kernel/metadata-type-redaction.test.ts` › `notCarriedForwardKeys` (4): the built-in names it; a row whose ONLY withheld position is not carried forward is still redacted by the stored-row seam (pins subset-not-disjoint); CONTROL the datasource built-in and the derived webhook redactor never set it; a registered redactor without it type-checks. Forced edits to existing tests: none. ## Acceptance notes - **Contract review:** per the ruling this is a `MetadataRedactionResult` contract change (spec + metadata-protocol) and takes a `CONTRACT_REVIEW_TIER` record; that record is the reviewing seat's, not this PR's. - **Two new public names await the maintainer:** the member `notCarriedForwardKeys` and the advisory rule id `withheld-key-dropped`. Alternatives considered for the member: `droppedOnSaveKeys`, `drainedKeys`. - **Deviation from the card delta's expected surface:** the delta said undeclared `auth` keys land "in the new list, not in `redactedKeys`". They land in both (subset). A disjoint list fails open: three existing readers decide "nothing withheld" from `redactedKeys.length === 0` (`redactStoredMetadataBody` and `redactMetadataItem`, and `packages/metadata/src/metadata-manager.ts` `redactServedDocument`), so a connector row whose only withheld position was an undeclared `auth` key would have been served in cleartext by every one of them, and by any third-party reader. The subset keeps all of them correct with no edit; only the carry-forward reads the new member. The ruling's words ("the redactor can mark a position") fit either. - **Cleared undeclared `auth` keys are now withheld too** (they were served as written). Not in the delta; included because the save of their served form was a 422 before this PR and the ruling wants that save to drain them. - **File surface beyond the claim's four files:** `packages/metadata-protocol/src/protocol.ts` (the save door threads `dropped` into `advisories`: 3 hunks) and `packages/spec/src/api/protocol.zod.ts` (the `advisories` description). - **Docs:** no hand-written page describes the connector read redaction or the save's carry-forward (`content/docs/automation/connectors.mdx` already sends credentials to `auth.credentialRef`), so the regenerated `protocol.mdx` and the changeset are the documentation change. - **Not in scope, unchanged:** a non-object `auth` (item 4) and every `authentication.KEY` (item 2) are still carried forward; `sys_metadata_history` snapshots keep the old body (served redacted by the stored-row seam); ①A (#29341) and the ③A export marker (#29342). ## Verification Readings on head `04ba73c2207187c08cb3566eab228eab9658976d`, tree `objectstack-ai/objectstack`. All on the merged head (main at `7f277a86` merged in), each heavy run under `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0` on every one). | Run | Reading | | --- | --- | | `turbo run build --filter='./packages/**'` | 78/78 tasks | | `@objectstack/spec` focused (redaction + protocol tests) | 3 files, 333/333 | | `@objectstack/metadata-protocol` focused (5 redaction / carry-forward files) | 5 files, 113/113 | | `@objectstack/spec` test (full) | 791 files, 23463 passed + 1 todo | | `@objectstack/spec` typecheck (incl. scripts + test typecheck) | exit 0 | | `@objectstack/metadata-protocol` test (full) | 304 files passed + 4 skipped; 22166 passed + 26 skipped | | `@objectstack/metadata-protocol` typecheck (`src/**/*`, tests included) | exit 0 | | `@objectstack/objectql` save / publish / advisory answer conformance | 3 files, 45/45 | | `@objectstack/metadata` redaction bookkeeping + service | 2 files, 81/81; typecheck exit 0 | | `@objectstack/service-automation` flow / connector credential files | 4 files, 72/72; typecheck exit 0 | | `@objectstack/runtime` flow credential files | 2 files, 13/13; typecheck exit 0 | | ESLint `--no-inline-config` on every changed `.ts` | exit 0 | | `pnpm --filter @objectstack/spec check:generated` | clean after the post-merge regen (the one prerequisite red was a pre-build dist) | | `check-changeset-no-major.mjs --event` on this body | exit 0 (`yes (widening)` / `none`, patch) | **Ablations** (pre-merge head, restored by blob hash after each): disabling the carry-forward's not-carried-forward skip turns 6 tests red; deleting the connector redactor's `notCarriedForwardKeys` push turns 7 red. <details><summary>Derived gates — <code>dispatch-gates.mjs --ran</code>: 106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN; every one exit 0</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. The new member is a type on the existing redaction contract; the connector edit extends the pure redactor `packages/spec` already hosts, and the carry-forward that acts on the mark lives in `metadata-protocol`. - [x] 2. Every name I added uses the casing for its kind (`notCarriedForwardKeys` camelCase member; `withheld-key-dropped` kebab-case rule id, like the gate's own rule ids). - [x] 3. No formatter ran; ESLint (the root `pnpm lint` command, `--no-inline-config`) is clean on every changed `.ts` file. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [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 (`content/docs/references/api/protocol.mdx`, regenerated again on the merged tree). - [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 `## 维护者速读` digest is the maintainer's requested Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de708b3 commit 7b293c4

12 files changed

Lines changed: 584 additions & 39 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(spec): a save of a connector's served form drops the leftover inline value under an undeclared `auth` key, and the save answer names each key it dropped
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A connector row stored before the instance-auth shapes went strict can still hold a value under an `auth` key its kind does not declare, typically an inline secret such as `auth.token`. The read already withheld it. A save of the served form, however, re-grafted it from the stored row, so the cleartext stayed at rest through every edit, although no door accepts the key and nothing at runtime reads it. Now:
12+
13+
- **The redactor can mark a withheld position as not carried forward.** `MetadataRedactionResult` (`@objectstack/spec/kernel`) gains an optional `notCarriedForwardKeys`: the positions among `redactedKeys` that a save drops instead of re-grafting. It is a subset, so `redactedKeys` still names every withheld position and a reader that knows only `redactedKeys` withholds exactly what it did before. Absent means none: the `datasource` built-in, the schema-derived redactors and every registered redactor keep carrying every withheld position forward, with nothing to change.
14+
- **The connector redactor marks its undeclared `auth` keys.** Each one is named in both lists. A cleared value there (`null`, `''`, an empty map) is now withheld too, because the strict shape refuses the key whatever it holds, so serving it made a plain read then save fail with `unrecognized_keys`. Every other withheld connector position (the four header and environment maps, `authentication`, a credential URL, an undeclared top-level, `retryConfig` or `actions` key, a non-object `auth`) is carried forward exactly as before.
15+
- **The save drops it and says so.** The `/meta` save (`saveMetaItem`, draft saves included) never re-grafts a not-carried-forward position, so the stored row loses it, and the answer's `advisories` carries one `warning` per dropped position: rule `withheld-key-dropped`, `where` naming the item, and `path` where the value sat at rest (`connectors[0].auth.token`). The notice never carries the value. The key is still omitted when there is nothing to report, so a save that drops nothing answers the same bytes as before. A value the incoming body itself holds at that position is the author's word and is not a drop.
16+
17+
If a connector still needs a credential that sat under such a key, store the secret outside the metadata and name it with `auth.credentialRef`.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2874,7 +2874,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
28742874
| **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. |
28752875
| **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. |
28762876
| **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. |
2877-
| **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`. |
2877+
| **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`). |
28782878
| **message** | `string` | optional | |
28792879

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

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
// Every value below is an obviously-fake placeholder.
99

1010
import { describe, it, expect } from 'vitest';
11-
import { carryForwardRedactedValues, redactMetadataItem } from './metadata-redaction.js';
11+
import {
12+
carryForwardRedactedValues,
13+
carryForwardRedactedValuesReportingDrops,
14+
redactMetadataItem,
15+
redactedPathsCarriedForward,
16+
} from './metadata-redaction.js';
1217

1318
const P = (n: number) => `TEST_PLACEHOLDER_UNDECLARED_${n}`;
1419

@@ -84,3 +89,55 @@ describe('carry-forward of the keys a connector does not declare', () => {
8489
expect(out).toEqual({ ...malformed, label: 'Vendor catalog (edited)' });
8590
});
8691
});
92+
93+
// [#28640 ②B] An undeclared `auth` key is withheld AND not carried forward: a
94+
// save of the served form drops it, and the drop is reported.
95+
describe('an undeclared `auth` key is not carried forward', () => {
96+
const legacy = {
97+
name: 'vendor_catalog',
98+
label: 'Vendor catalog',
99+
type: 'api',
100+
provider: 'rest',
101+
apiKey: P(1),
102+
providerConfig: { baseUrl: 'https://api.vendor.example/v1', headers: { Authorization: P(6) } },
103+
auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(7) },
104+
};
105+
const servedLegacy = () => JSON.parse(JSON.stringify(redactMetadataItem('connector', legacy))) as Record<string, any>;
106+
107+
it('the save drops it, names it, and still carries every other withheld position (undeclared root key, header map)', () => {
108+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', servedLegacy(), legacy);
109+
expect(item).toEqual({ ...legacy, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' } });
110+
expect(dropped).toEqual(['auth.token']);
111+
expect(carryForwardRedactedValues('connector', servedLegacy(), legacy)).toEqual(item);
112+
});
113+
114+
it('the runtime gate is never told it will be restored', () => {
115+
expect(redactedPathsCarriedForward('connector', servedLegacy(), legacy).sort())
116+
.toEqual(['apiKey', 'providerConfig.headers']);
117+
});
118+
119+
it('a value the incoming body holds at that position is the author\'s word, not a drop', () => {
120+
// `headerName` is undeclared on `bearer` and declared on `api-key`: the
121+
// author switched kinds and wrote it.
122+
const atRest = { ...legacy, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', headerName: P(8) } };
123+
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', atRest))) as Record<string, any>;
124+
expect(body.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' });
125+
body.auth = { type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Key' };
126+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, atRest);
127+
expect(dropped).toEqual([]);
128+
expect((item as Record<string, any>).auth).toEqual({ type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Key' });
129+
});
130+
131+
it('a body that removes `auth` drops it too, and says so', () => {
132+
const body = servedLegacy();
133+
delete body.auth;
134+
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, legacy);
135+
expect(item).not.toHaveProperty('auth');
136+
expect(dropped).toEqual(['auth.token']);
137+
});
138+
139+
it('CONTROL: a row with no undeclared `auth` key reports no drop', () => {
140+
const { dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored);
141+
expect(dropped).toEqual([]);
142+
});
143+
});

‎packages/metadata-protocol/src/metadata-redaction.ts‎

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,13 @@
4848
*
4949
* ⛔ It does NOT create cleartext, and does not migrate it out either: it
5050
* preserves what is already at rest. Getting stored cleartext OUT of the store
51-
* is #8081 item 3's migration and is deliberately not attempted here.
51+
* is #8081 item 3's migration and is deliberately not attempted here — with
52+
* ONE exception the redactor itself declares. A position it names in
53+
* `notCarriedForwardKeys` (#28640 ②B, ruling 6566912252: a connector's
54+
* undeclared `auth` keys, which no door accepts and nothing reads) is never
55+
* re-grafted, so a save of the served form drops it from the row, and the
56+
* save names each dropped position on its answer
57+
* ({@link carryForwardRedactedValuesReportingDrops}).
5258
*
5359
* ## Why nothing is stamped on the wire
5460
*
@@ -448,6 +454,11 @@ function sameValue(a: unknown, b: unknown): boolean {
448454
* - the incoming body has no container for that path at all ⇒ nothing is
449455
* grafted, because a removed container is also the author's word.
450456
*
457+
* Those three are for a path in `redactedKeys` only. A path the redactor also
458+
* names in `notCarriedForwardKeys` (#28640 ②B) is never grafted, whatever the
459+
* incoming body holds there: a save of the served form drops it, and
460+
* {@link carryForwardRedactedValuesReportingDrops} reports the drop.
461+
*
451462
* A path through an ARRAY (a flow's start node, `nodes.<i>.config.secret`,
452463
* #20552) is walked by the stored element's `id`, not by its index, so a body
453464
* that reorders the array still carries the value onto the element it came
@@ -520,9 +531,39 @@ export function redactedPathsCarriedForward(type: string, incoming: unknown, sto
520531
}
521532

522533
/**
523-
* The one decision behind {@link carryForwardRedactedValues} and
524-
* {@link redactedPathsCarriedForward}: the grafted body, and where each graft
525-
* landed. `landed` is empty exactly when `out` is `incoming` by reference.
534+
* [#28640 ②B] {@link carryForwardRedactedValues}, and what the same save DROPS
535+
* from the row at rest: every position the type's redactor names in
536+
* `notCarriedForwardKeys` for the stored body (withheld AND not carried
537+
* forward — a connector's undeclared `auth` keys) where the stored body holds
538+
* a value and the incoming body holds none. Such a position is never
539+
* re-grafted, so a save of the served form persists the row without it; the
540+
* dotted paths (the redactor's spelling, sorted) are what the save names on
541+
* its answer, so the drop is loud rather than silent.
542+
*
543+
* A position the incoming body DOES hold a value at is not dropped: that
544+
* value is the author's word and is judged at the door like any other (an
545+
* `auth` kind the author switched to may declare the key). Not on the
546+
* package entry: the save door's half, consumed inside this package only.
547+
*
548+
* @param type request-shaped metadata type (plural or singular).
549+
* @param incoming the body about to be persisted.
550+
* @param stored the body currently at rest, RAW (never a served copy).
551+
*/
552+
export function carryForwardRedactedValuesReportingDrops<T>(
553+
type: string,
554+
incoming: T,
555+
stored: unknown,
556+
): { item: T; dropped: string[] } {
557+
const plan = planCarryForward(type, incoming, stored);
558+
return { item: plan.out, dropped: plan.dropped };
559+
}
560+
561+
/**
562+
* The one decision behind {@link carryForwardRedactedValues},
563+
* {@link redactedPathsCarriedForward} and
564+
* {@link carryForwardRedactedValuesReportingDrops}: the grafted body, where
565+
* each graft landed, and which not-carried-forward stored positions the body
566+
* drops. `landed` is empty exactly when `out` is `incoming` by reference.
526567
*/
527568
/**
528569
* A `redactedKeys` entry resolved against the STORED body into the container
@@ -559,22 +600,39 @@ function resolveRedactedPath(
559600
return undefined;
560601
}
561602

562-
function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out: T; landed: string[] } {
563-
const unchanged = { out: incoming, landed: [] as string[] };
564-
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return unchanged;
565-
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return unchanged;
603+
function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out: T; landed: string[]; dropped: string[] } {
604+
const none = { out: incoming, landed: [] as string[], dropped: [] as string[] };
605+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return none;
606+
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return none;
566607
const redactor = redactorFor(type);
567-
if (!redactor) return unchanged;
608+
if (!redactor) return none;
568609

569610
// What a read exit WOULD have served for the row at rest. Computed from the
570611
// stored body rather than remembered from a response, so the comparison
571612
// holds for any caller — Studio, the CLI, a raw `curl` — and needs no
572613
// session state.
573614
const served = redactor(stored as Record<string, unknown>);
574-
if (served.redactedKeys.length === 0) return unchanged;
615+
if (served.redactedKeys.length === 0) return none;
616+
617+
// [#28640 ②B] The withheld positions the redactor marks NOT carried
618+
// forward: never re-grafted, so the save drops each one the stored row
619+
// holds and the incoming body does not — and reports it (`dropped`).
620+
const notCarriedForward = new Set(served.notCarriedForwardKeys ?? []);
621+
const dropped: string[] = [];
622+
const unchanged = { out: incoming, landed: [] as string[], dropped };
575623

576624
const grafts: Array<{ at: string[]; key: string; value: unknown }> = [];
577625
for (const path of served.redactedKeys) {
626+
if (notCarriedForward.has(path)) {
627+
const resolved = resolveRedactedPath(stored, path);
628+
if (!resolved) continue;
629+
const target = locate(incoming, resolved.hops);
630+
const authored = target !== undefined
631+
&& Object.prototype.hasOwnProperty.call(target.container, resolved.key)
632+
&& target.container[resolved.key] !== undefined;
633+
if (!authored) dropped.push(path);
634+
continue;
635+
}
578636
// Dotted, item-relative — the registry's documented contract for
579637
// `redactedKeys` (`config.password`; an array hop is an index into the
580638
// stored body, `nodes.0.config.secret`, resolved to an identity by
@@ -621,7 +679,7 @@ function planCarryForward<T>(type: string, incoming: T, stored: unknown): { out:
621679
if (kept.length === 0) return unchanged;
622680
const withheld = new Set(redactor(out as Record<string, unknown>).redactedKeys);
623681
const next = kept.filter((graft) => withheld.has(landing(graft)));
624-
if (next.length === kept.length) return { out: out as T, landed: kept.map(landing) };
682+
if (next.length === kept.length) return { out: out as T, landed: kept.map(landing), dropped };
625683
kept = next;
626684
}
627685
}

0 commit comments

Comments
 (0)