diff --git a/.changeset/28640-withheld-not-carried-forward.md b/.changeset/28640-withheld-not-carried-forward.md new file mode 100644 index 00000000000..8559cb1fcbf --- /dev/null +++ b/.changeset/28640-withheld-not-carried-forward.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': patch +'@objectstack/metadata-protocol': patch +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: none + +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: + +- **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. +- **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. +- **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. + +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`. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 9d122a73e74..faebfb91a12 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2874,7 +2874,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **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. | | **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. | | **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. | -| **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`. | +| **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`). | | **message** | `string` | optional | | ### Nested Shape: `SaveMetaItemResponse.projectionApplied` diff --git a/packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts b/packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts index cb232c02509..5d52e269f1f 100644 --- a/packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts +++ b/packages/metadata-protocol/src/metadata-redaction.connector-undeclared-keys.test.ts @@ -8,7 +8,12 @@ // Every value below is an obviously-fake placeholder. import { describe, it, expect } from 'vitest'; -import { carryForwardRedactedValues, redactMetadataItem } from './metadata-redaction.js'; +import { + carryForwardRedactedValues, + carryForwardRedactedValuesReportingDrops, + redactMetadataItem, + redactedPathsCarriedForward, +} from './metadata-redaction.js'; const P = (n: number) => `TEST_PLACEHOLDER_UNDECLARED_${n}`; @@ -84,3 +89,55 @@ describe('carry-forward of the keys a connector does not declare', () => { expect(out).toEqual({ ...malformed, label: 'Vendor catalog (edited)' }); }); }); + +// [#28640 ②B] An undeclared `auth` key is withheld AND not carried forward: a +// save of the served form drops it, and the drop is reported. +describe('an undeclared `auth` key is not carried forward', () => { + const legacy = { + name: 'vendor_catalog', + label: 'Vendor catalog', + type: 'api', + provider: 'rest', + apiKey: P(1), + providerConfig: { baseUrl: 'https://api.vendor.example/v1', headers: { Authorization: P(6) } }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(7) }, + }; + const servedLegacy = () => JSON.parse(JSON.stringify(redactMetadataItem('connector', legacy))) as Record; + + it('the save drops it, names it, and still carries every other withheld position (undeclared root key, header map)', () => { + const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', servedLegacy(), legacy); + expect(item).toEqual({ ...legacy, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' } }); + expect(dropped).toEqual(['auth.token']); + expect(carryForwardRedactedValues('connector', servedLegacy(), legacy)).toEqual(item); + }); + + it('the runtime gate is never told it will be restored', () => { + expect(redactedPathsCarriedForward('connector', servedLegacy(), legacy).sort()) + .toEqual(['apiKey', 'providerConfig.headers']); + }); + + it('a value the incoming body holds at that position is the author\'s word, not a drop', () => { + // `headerName` is undeclared on `bearer` and declared on `api-key`: the + // author switched kinds and wrote it. + const atRest = { ...legacy, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', headerName: P(8) } }; + const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', atRest))) as Record; + expect(body.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' }); + body.auth = { type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Key' }; + const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, atRest); + expect(dropped).toEqual([]); + expect((item as Record).auth).toEqual({ type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Key' }); + }); + + it('a body that removes `auth` drops it too, and says so', () => { + const body = servedLegacy(); + delete body.auth; + const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, legacy); + expect(item).not.toHaveProperty('auth'); + expect(dropped).toEqual(['auth.token']); + }); + + it('CONTROL: a row with no undeclared `auth` key reports no drop', () => { + const { dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored); + expect(dropped).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/metadata-redaction.ts b/packages/metadata-protocol/src/metadata-redaction.ts index ca41d90ecdc..a7e8415e90d 100644 --- a/packages/metadata-protocol/src/metadata-redaction.ts +++ b/packages/metadata-protocol/src/metadata-redaction.ts @@ -48,7 +48,13 @@ * * ⛔ It does NOT create cleartext, and does not migrate it out either: it * preserves what is already at rest. Getting stored cleartext OUT of the store - * is #8081 item 3's migration and is deliberately not attempted here. + * is #8081 item 3's migration and is deliberately not attempted here — with + * ONE exception the redactor itself declares. A position it names in + * `notCarriedForwardKeys` (#28640 ②B, ruling 6566912252: a connector's + * undeclared `auth` keys, which no door accepts and nothing reads) is never + * re-grafted, so a save of the served form drops it from the row, and the + * save names each dropped position on its answer + * ({@link carryForwardRedactedValuesReportingDrops}). * * ## Why nothing is stamped on the wire * @@ -448,6 +454,11 @@ function sameValue(a: unknown, b: unknown): boolean { * - the incoming body has no container for that path at all ⇒ nothing is * grafted, because a removed container is also the author's word. * + * Those three are for a path in `redactedKeys` only. A path the redactor also + * names in `notCarriedForwardKeys` (#28640 ②B) is never grafted, whatever the + * incoming body holds there: a save of the served form drops it, and + * {@link carryForwardRedactedValuesReportingDrops} reports the drop. + * * A path through an ARRAY (a flow's start node, `nodes..config.secret`, * #20552) is walked by the stored element's `id`, not by its index, so a body * 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 } /** - * The one decision behind {@link carryForwardRedactedValues} and - * {@link redactedPathsCarriedForward}: the grafted body, and where each graft - * landed. `landed` is empty exactly when `out` is `incoming` by reference. + * [#28640 ②B] {@link carryForwardRedactedValues}, and what the same save DROPS + * from the row at rest: every position the type's redactor names in + * `notCarriedForwardKeys` for the stored body (withheld AND not carried + * forward — a connector's undeclared `auth` keys) where the stored body holds + * a value and the incoming body holds none. Such a position is never + * re-grafted, so a save of the served form persists the row without it; the + * dotted paths (the redactor's spelling, sorted) are what the save names on + * its answer, so the drop is loud rather than silent. + * + * A position the incoming body DOES hold a value at is not dropped: that + * value is the author's word and is judged at the door like any other (an + * `auth` kind the author switched to may declare the key). Not on the + * package entry: the save door's half, consumed inside this package only. + * + * @param type request-shaped metadata type (plural or singular). + * @param incoming the body about to be persisted. + * @param stored the body currently at rest, RAW (never a served copy). + */ +export function carryForwardRedactedValuesReportingDrops( + type: string, + incoming: T, + stored: unknown, +): { item: T; dropped: string[] } { + const plan = planCarryForward(type, incoming, stored); + return { item: plan.out, dropped: plan.dropped }; +} + +/** + * The one decision behind {@link carryForwardRedactedValues}, + * {@link redactedPathsCarriedForward} and + * {@link carryForwardRedactedValuesReportingDrops}: the grafted body, where + * each graft landed, and which not-carried-forward stored positions the body + * drops. `landed` is empty exactly when `out` is `incoming` by reference. */ /** * A `redactedKeys` entry resolved against the STORED body into the container @@ -559,22 +600,39 @@ function resolveRedactedPath( return undefined; } -function planCarryForward(type: string, incoming: T, stored: unknown): { out: T; landed: string[] } { - const unchanged = { out: incoming, landed: [] as string[] }; - if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return unchanged; - if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return unchanged; +function planCarryForward(type: string, incoming: T, stored: unknown): { out: T; landed: string[]; dropped: string[] } { + const none = { out: incoming, landed: [] as string[], dropped: [] as string[] }; + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return none; + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return none; const redactor = redactorFor(type); - if (!redactor) return unchanged; + if (!redactor) return none; // What a read exit WOULD have served for the row at rest. Computed from the // stored body rather than remembered from a response, so the comparison // holds for any caller — Studio, the CLI, a raw `curl` — and needs no // session state. const served = redactor(stored as Record); - if (served.redactedKeys.length === 0) return unchanged; + if (served.redactedKeys.length === 0) return none; + + // [#28640 ②B] The withheld positions the redactor marks NOT carried + // forward: never re-grafted, so the save drops each one the stored row + // holds and the incoming body does not — and reports it (`dropped`). + const notCarriedForward = new Set(served.notCarriedForwardKeys ?? []); + const dropped: string[] = []; + const unchanged = { out: incoming, landed: [] as string[], dropped }; const grafts: Array<{ at: string[]; key: string; value: unknown }> = []; for (const path of served.redactedKeys) { + if (notCarriedForward.has(path)) { + const resolved = resolveRedactedPath(stored, path); + if (!resolved) continue; + const target = locate(incoming, resolved.hops); + const authored = target !== undefined + && Object.prototype.hasOwnProperty.call(target.container, resolved.key) + && target.container[resolved.key] !== undefined; + if (!authored) dropped.push(path); + continue; + } // Dotted, item-relative — the registry's documented contract for // `redactedKeys` (`config.password`; an array hop is an index into the // stored body, `nodes.0.config.secret`, resolved to an identity by @@ -621,7 +679,7 @@ function planCarryForward(type: string, incoming: T, stored: unknown): { out: if (kept.length === 0) return unchanged; const withheld = new Set(redactor(out as Record).redactedKeys); const next = kept.filter((graft) => withheld.has(landing(graft))); - if (next.length === kept.length) return { out: out as T, landed: kept.map(landing) }; + if (next.length === kept.length) return { out: out as T, landed: kept.map(landing), dropped }; kept = next; } } diff --git a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts index 34deefa4f48..91560041089 100644 --- a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts +++ b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts @@ -45,8 +45,59 @@ import type { MetadataCredentialChannel, ObjectStackProtocolImplementation } from './protocol.js'; import type { SysMetadataRepository } from './sys-metadata-repository.js'; -import { carryForwardRedactedValues, hasMetadataRedactor, redactedPathsCarriedForward } from './metadata-redaction.js'; -import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { carryForwardRedactedValuesReportingDrops, hasMetadataRedactor, redactedPathsCarriedForward } from './metadata-redaction.js'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import type { RuntimeAuthoringIssue } from '@objectstack/spec/api'; + +/** + * [#28640 ②B] The rule id of the save notice that names a stored position the + * save dropped because the type's redactor marks it withheld AND not carried + * forward (`MetadataRedactionResult.notCarriedForwardKeys`). Stable across + * releases: the key a client groups or suppresses the notice by. + */ +export const WITHHELD_KEY_DROPPED = 'withheld-key-dropped'; + +/** + * [#28640 ②B] One `warning` per stored position this save dropped + * ({@link carryForwardRedactedCredentials}' `dropped`), on the save answer's + * `advisories` — the channel a 2xx save already carries its non-gating + * findings on, so a drop at rest is loud instead of silent, and no second + * channel is added. `[]` when nothing was dropped, so the answer of a save + * that drops nothing is unchanged. + * + * `path` follows the advisory convention for a write that is the sole member + * of its own collection (`SaveMetaItemResponseSchema.advisories`): the + * written item is `[0]`, so a connector's `auth.token` is + * `connectors[0].auth.token`. The position is one the served body never held, + * so it names where the key sat in the body the carry-forward read: the + * stored row, or the code layer a first save starts from (#20552). + * + * `message` names the key and never its value, and it is true of every case + * that reaches here: a stored value, a cleared one (`null`, `''`), and a + * first save over the code layer, whose new row never held the key. + */ +export function droppedWithheldKeyNotices(args: { + type: string; + name: string; + dropped: readonly string[]; +}): RuntimeAuthoringIssue[] { + if (args.dropped.length === 0) return []; + const singular = PLURAL_TO_SINGULAR[args.type] ?? args.type; + const root = `${SINGULAR_TO_PLURAL[singular] ?? singular}[0]`; + return args.dropped.map((dotted) => ({ + rule: WITHHELD_KEY_DROPPED, + severity: 'warning' as const, + where: `${singular} "${args.name}"`, + path: root + dotted.split('.').map((segment) => (/^(0|[1-9][0-9]*)$/.test(segment) ? `[${segment}]` : `.${segment}`)).join(''), + message: + `this save did not carry '${dotted}' forward: the read withholds that position and no save keeps it ` + + '(no authoring door accepts it and nothing at runtime reads it), so the row this save wrote holds nothing there.', + hint: + 'Nothing to restore if it was a leftover inline secret. If the item still needs that credential, store ' + + 'the secret outside the metadata and name it through the declared reference (a connector\'s ' + + '`auth.credentialRef`).', + })); +} /** * The `ObjectStackProtocolImplementation` members the functions below read — @@ -71,7 +122,14 @@ export function credentialChannelFor( return this.credentialChannels.get(PLURAL_TO_SINGULAR[type] ?? type); } -/** The body of `ObjectStackProtocolImplementation#carryForwardRedactedCredentials`; the method's docblock there is its contract. */ +/** + * The body of `ObjectStackProtocolImplementation#carryForwardRedactedCredentials`; the method's docblock there is its contract. + * + * [#28640 ②B] The one body here edited since the move: it answers the stored + * positions the save DROPS (`dropped`, the redactor's `notCarriedForwardKeys` + * spelling) beside the grafted body, so the save door can name each on its + * answer. Same reads, in the same order. + */ export async function carryForwardRedactedCredentials( this: Pick, args: { @@ -83,11 +141,11 @@ export async function carryForwardRedactedCredentials( item: any; /** [#22877] The bundle member the save addresses (its body's). */ member?: string; - }): Promise { - if (!hasMetadataRedactor(args.type)) return args.item; + }): Promise<{ item: any; dropped: string[] }> { + if (!hasMetadataRedactor(args.type)) return { item: args.item, dropped: [] }; const body = await this.storedBodyForCarryForward(args); - if (!body) return args.item; - return carryForwardRedactedValues(args.type, args.item, body); + if (!body) return { item: args.item, dropped: [] }; + return carryForwardRedactedValuesReportingDrops(args.type, args.item, body); } /** The body of `ObjectStackProtocolImplementation#storedBodyForCarryForward`; the method's docblock there is its contract. */ diff --git a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts index d9702da29b5..2d60e0616c6 100644 --- a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts @@ -1680,3 +1680,135 @@ describe('the read exits withhold the declared ssl secret member of a datasource expect(stored.ssl).toEqual({ ...SSL_PUBLIC, key: SSL_KEY }); }); }); + +// --------------------------------------------------------------------------- +// [#28640 ②B] Legacy cleartext under a connector's undeclared `auth` key drains +// on a normal save, and the save names what it dropped +// --------------------------------------------------------------------------- +// +// The instance-auth shapes are strict, so the save door refuses an undeclared +// `auth` key: a row holding one was written before they were, and only a direct +// seed reproduces it. The redactor marks the position withheld AND not carried +// forward, so a save of the served form persists the row without it. + +const LEGACY_AUTH_TOKEN = 'TEST_PLACEHOLDER_LEGACY_AUTH_TOKEN'; +const MAP_HEADER_VALUE = 'TEST_PLACEHOLDER_MAP_HEADER_VALUE'; + +function legacyAuthConnectorBody() { + return { + name: 'vendor_api', + label: 'Vendor API', + type: 'api', + provider: 'rest', + // A DECLARED credential position (withheld whole, carried forward): the control. + providerConfig: { baseUrl: 'https://api.vendor.example/v1', headers: { Authorization: `Bearer ${MAP_HEADER_VALUE}` } }, + auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN', token: LEGACY_AUTH_TOKEN }, + }; +} + +function seedLegacyAuthConnectorRow(rows: Map, body: Record = legacyAuthConnectorBody()) { + const where = { type: 'connector', name: 'vendor_api', organization_id: null, package_id: null, state: 'active' }; + rows.set(keyOf(where), { + id: 'r_connector_legacy_auth', ...where, metadata: JSON.stringify(body), checksum: hashSpec(body), version: 1, + } as Row); +} + +const droppedNotice = (path: string) => expect.objectContaining({ + rule: 'withheld-key-dropped', + severity: 'warning', + where: 'connector "vendor_api"', + path, +}); + +describe('[#28640 ②B] a save of the served connector form drains an undeclared `auth` key', () => { + it('read serves without it → save of the served form stores no `auth.token` and names it on `advisories`', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyAuthConnectorRow(rows); + const protocol = new ObjectStackProtocolImplementation(engine); + + const read: any = await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' }); + expect(read.item.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }); + expect(allStrings(read)).not.toContain(LEGACY_AUTH_TOKEN); + + const res: any = await protocol.saveMetaItem({ + type: 'connector', name: 'vendor_api', item: { ...read.item, label: 'Vendor API (edited)' }, + }); + + const atRest = storedConnector(rows); + expect(atRest.label).toBe('Vendor API (edited)'); + expect(atRest.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }); + expect(allStrings(atRest)).not.toContain(LEGACY_AUTH_TOKEN); + expect(res.advisories).toEqual([droppedNotice('connectors[0].auth.token')]); + // The notice names the position, never the value it dropped. + expect(allStrings(res)).not.toContain(LEGACY_AUTH_TOKEN); + }); + + it('CONTROL: the declared credential position beside it (a header map) round-trips through the carry-forward unchanged', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyAuthConnectorRow(rows); + const protocol = new ObjectStackProtocolImplementation(engine); + + const served: any = (await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' })).item; + expect(served.providerConfig).toEqual({ baseUrl: 'https://api.vendor.example/v1' }); + await protocol.saveMetaItem({ type: 'connector', name: 'vendor_api', item: served }); + + expect(storedConnector(rows).providerConfig).toEqual(legacyAuthConnectorBody().providerConfig); + }); + + it('the drain converges: a second save of the served form drops nothing and carries no `advisories` key', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyAuthConnectorRow(rows); + const protocol = new ObjectStackProtocolImplementation(engine); + + const first: any = (await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' })).item; + await protocol.saveMetaItem({ type: 'connector', name: 'vendor_api', item: first }); + const second: any = (await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' })).item; + const res: any = await protocol.saveMetaItem({ type: 'connector', name: 'vendor_api', item: second }); + + expect(res).not.toHaveProperty('advisories'); + expect(storedConnector(rows).providerConfig.headers).toEqual(legacyAuthConnectorBody().providerConfig.headers); + }); + + it('a draft save drains too, and names the drop on its answer', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyAuthConnectorRow(rows); + const protocol = new ObjectStackProtocolImplementation(engine); + + const served: any = (await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' })).item; + const res: any = await protocol.saveMetaItem({ type: 'connector', name: 'vendor_api', item: served, mode: 'draft' }); + + expect(res.state).toBe('draft'); + expect(res.advisories).toEqual([droppedNotice('connectors[0].auth.token')]); + expect(allStrings(res)).not.toContain(LEGACY_AUTH_TOKEN); + const draft = JSON.parse(Array.from(rows.values()).find((r) => r.type === 'connector' && r.state === 'draft')!.metadata); + expect(draft.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }); + }); + + it('a cleared undeclared `auth` key is withheld too, so the served form saves instead of meeting the strict shape', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyAuthConnectorRow(rows, { ...legacyAuthConnectorBody(), auth: { type: 'bearer', credentialRef: 'VENDOR_API_TOKEN', token: null } }); + const protocol = new ObjectStackProtocolImplementation(engine); + + const served: any = (await protocol.getMetaItem({ type: 'connector', name: 'vendor_api' })).item; + expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }); + const res: any = await protocol.saveMetaItem({ type: 'connector', name: 'vendor_api', item: served }); + + expect(storedConnector(rows).auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_API_TOKEN' }); + expect(res.advisories).toEqual([droppedNotice('connectors[0].auth.token')]); + // The message names the key and claims no stored value: here there was none. + expect(res.advisories[0].message).toContain("'auth.token'"); + expect(res.advisories[0].message).not.toMatch(/held a value/); + }); + + it('CONTROL: a datasource row\'s carry-forward is unchanged — its credentials stay at rest and the answer carries no `advisories`', async () => { + const { engine, rows } = makeStubEngine(); + seedLegacyRow(rows); + const protocol = new ObjectStackProtocolImplementation(engine); + + const served: any = (await protocol.getMetaItem({ type: 'datasource', name: 'warehouse' })).item; + const res: any = await protocol.saveMetaItem({ type: 'datasource', name: 'warehouse', item: { ...served, label: 'Warehouse (edited)' } }); + + expect(storedBody(rows)).toEqual({ ...legacyDatasourceBody(), label: 'Warehouse (edited)' }); + expect(res).not.toHaveProperty('advisories'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 03b6888b0bc..e3e8ecb42df 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -336,6 +336,7 @@ import { import { carryForwardRedactedCredentials, credentialChannelFor, + droppedWithheldKeyNotices, restoredCredentialPathsFor, storedBodyForCarryForward, } from './protocol-credential-carry-forward.js'; @@ -3789,8 +3790,10 @@ export class ObjectStackProtocolImplementation implements item: any; /** [#22877] The bundle member the save addresses (its body's). */ member?: string; - }): Promise { + }): Promise<{ item: any; dropped: string[] }> { // [#25467 cut 9] The body moved to `protocol-credential-carry-forward.ts` (see `CredentialCarryForwardHost`). + // [#28640 ②B] It answers the grafted body and the stored positions the + // save drops (the redactor's `notCarriedForwardKeys`), never re-grafted. const host: ThisParameterType = { storedBodyForCarryForward: (...a) => this.storedBodyForCarryForward(...a), }; @@ -14969,7 +14972,13 @@ export class ObjectStackProtocolImplementation implements // lands (`restoredCredentialPathsFor`, the same plan over the same // stored body), never what it restores — so a rule reads a withheld // credential as present without any gate handling it. - request.item = await this.carryForwardRedactedCredentials({ + // + // [#28640 ②B] …except the positions the type's redactor marks withheld + // AND not carried forward (a connector's undeclared `auth` keys, which + // no door accepts and nothing reads): those are never re-grafted, so + // this save drops them from the row at rest, and the answer names each + // one on `advisories` (`droppedWithheldKeyNotices`) — loud, never silent. + const carried = await this.carryForwardRedactedCredentials({ type: singularTypeForRepo, repo, ref, @@ -14978,6 +14987,11 @@ export class ObjectStackProtocolImplementation implements item: request.item, member: itemDiscriminator(singularTypeForRepo, request.item), }); + request.item = carried.item; + const saveNotices = [ + ...runtimeAdvisories, + ...droppedWithheldKeyNotices({ type: singularTypeForRepo, name: request.name, dropped: carried.dropped }), + ]; // [#20790] …and then OUT of the body: a type with a write-only // credential channel (`flow`, registered by the automation plugin) // stores every explicit credential there and persists the body without @@ -15098,7 +15112,11 @@ export class ObjectStackProtocolImplementation implements // bypass) and that door does NOT carry this field yet — its own // response contract only just landed as #7294. The asymmetry is // deliberate and stated in the changeset. - ...(runtimeAdvisories.length > 0 ? { advisories: runtimeAdvisories } : {}), + // + // [#28640 ②B] The save's own notices ride the same key: a stored + // position the carry-forward dropped is named here, after the + // gate's findings, and the key is still omitted when both are empty. + ...(saveNotices.length > 0 ? { advisories: saveNotices } : {}), // #5745 — the literal union, not `string`. An object-literal // property widens a two-literal ternary to `string`, which made // this method fail to satisfy `MetadataProtocol.saveMetaItem` diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 2202a4f0c9e..744b13f7428 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1229,7 +1229,14 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ + '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`.', + + '`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`).', ), message: z.string().optional(), })); diff --git a/packages/spec/src/integration/connector-credential-redaction.test.ts b/packages/spec/src/integration/connector-credential-redaction.test.ts index 3b4bdecacb4..5ba6764f497 100644 --- a/packages/spec/src/integration/connector-credential-redaction.test.ts +++ b/packages/spec/src/integration/connector-credential-redaction.test.ts @@ -419,6 +419,81 @@ describe('the connector redactor — purity and the untouched remainder', () => }); }); +// [#28640 ②B, ruling 6566912252] Which withheld positions a save does NOT carry +// forward: the undeclared `auth` keys, and nothing else. +describe('the connector redactor — withheld and not carried forward', () => { + it('names every undeclared `auth` key in `notCarriedForwardKeys` as well as in `redactedKeys`', () => { + const { item: served, redactedKeys, notCarriedForwardKeys } = redactConnectorItem({ + ...base, auth: { type: 'basic', username: 'svc', credentialRef: 'PW', password: P(70), token: P(71) }, + }); + expect(redactedKeys).toEqual(['auth.password', 'auth.token']); + expect(notCarriedForwardKeys).toEqual(['auth.password', 'auth.token']); + expect(served.auth).toEqual({ type: 'basic', username: 'svc', credentialRef: 'PW' }); + }); + + it('a `type` that names no kind: every key but `type` is undeclared, so none is carried forward', () => { + const { redactedKeys, notCarriedForwardKeys } = redactConnectorItem({ ...base, auth: { type: 'digest', credentialRef: 'X', secret: P(72) } }); + expect(notCarriedForwardKeys).toEqual(redactedKeys); + }); + + it.each([ + ['a null', null], + ['an empty string', ''], + ['an empty map', {}], + ] as const)('withholds a cleared undeclared `auth` key (%s) too, so the served form passes the closed entry', (_what, value) => { + const item = { + name: 'vendor', label: 'Vendor', type: 'api', provider: 'rest', + providerConfig: { baseUrl: 'https://v.example' }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: value }, + }; + // The reason: the strict instance-auth shape refuses the key whatever it holds. + expect(DeclarativeConnectorEntrySchema.safeParse(item).success).toBe(false); + const { item: served, redactedKeys, notCarriedForwardKeys } = redactConnectorItem(item); + expect(redactedKeys).toEqual(['auth.token']); + expect(notCarriedForwardKeys).toEqual(['auth.token']); + expect(DeclarativeConnectorEntrySchema.safeParse(served).success).toBe(true); + }); + + it('CONTROL: every other withheld position stays carried forward — the member is absent', () => { + const result = redactConnectorItem({ + ...base, + apiKey: P(73), + authentication: { type: 'api-key', key: P(74) }, + retryConfig: { strategy: 'exponential_backoff', secret: P(75) }, + actions: [{ key: 'get_invoice', label: 'Get invoice', token: P(76) }], + providerConfig: { + baseUrl: `https://u:${P(77)}@v.example`, + headers: { Authorization: P(78) }, + defaultHeaders: { Accept: 'application/json' }, + transport: { kind: 'http', url: 'https://mcp.example.com', headers: { Cookie: P(79) }, env: { TOKEN: P(80) } }, + }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' }, + }); + expect(result.redactedKeys).toEqual([ + 'actions.0.token', 'apiKey', 'authentication.key', 'providerConfig.baseUrl', 'providerConfig.defaultHeaders', + 'providerConfig.headers', 'providerConfig.transport.env', 'providerConfig.transport.headers', 'retryConfig.secret', + ]); + expect(result).not.toHaveProperty('notCarriedForwardKeys'); + }); + + it('CONTROL: `auth` that is not an object is withheld whole and still carried forward', () => { + const result = redactConnectorItem({ ...base, auth: P(81) }); + expect(result.redactedKeys).toEqual(['auth']); + expect(result).not.toHaveProperty('notCarriedForwardKeys'); + }); + + it('beside the other positions, only the undeclared `auth` key is marked', () => { + const { redactedKeys, notCarriedForwardKeys } = redactConnectorItem({ + ...base, + apiKey: P(82), + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(83) }, + providerConfig: { headers: { Authorization: P(84) } }, + }); + expect(redactedKeys).toEqual(['apiKey', 'auth.token', 'providerConfig.headers']); + expect(notCarriedForwardKeys).toEqual(['auth.token']); + }); +}); + /** * Fail-closed census: the redactor serves every key an instance-auth kind * DECLARES, so a secret that became a declared key would be served. Every key diff --git a/packages/spec/src/integration/connector-credential-redaction.ts b/packages/spec/src/integration/connector-credential-redaction.ts index beb4428739d..37b16d48e4d 100644 --- a/packages/spec/src/integration/connector-credential-redaction.ts +++ b/packages/spec/src/integration/connector-credential-redaction.ts @@ -28,6 +28,18 @@ * {@link ConnectorInstanceAuthSchema} itself, so a kind added to the union * later is covered with nothing to edit here. A `type` that names no kind * leaves `type` alone declared. + * + * **Withheld and NOT carried forward** (#28640 ②B, ruling 6566912252): + * each such position is named in `notCarriedForwardKeys` as well as in + * `redactedKeys`. Every instance-auth kind is a strict shape, so the save + * door refuses an undeclared `auth` key whatever its value, and nothing at + * runtime reads one: a stored value there is cleartext a row written + * before the shapes went strict still holds, and keeping it through a save + * keeps nothing but the exposure. So a save of the served form drops it, + * and the save names each key it dropped. For the same reason a CLEARED + * value (`null`, `''`, an empty map) is withheld here too, unlike the + * credential positions below: serving it back would make a plain read then + * save meet the strict shape's refusal. * 2. **`authentication.` — every key but `type`.** The runtime shape * carries its secret inline (`token`, `key`, `password`), and an authored * entry may hold only `{ type: 'none' }` (the publish refusal registered as @@ -107,7 +119,9 @@ * * A position holding nothing — `null`, `''`, or an empty map — is served as * written and not reported, as the schema derivation serves its cleared forms: - * a reader can tell "cleared" from "withheld". Every other key of the item is + * a reader can tell "cleared" from "withheld". The undeclared keys (items 1, 7 + * and 8) are the exception, each for the reason its item gives: no door + * accepts them, so their cleared forms are withheld too. Every other key of the item is * left byte for byte, `_diagnostics` included, and the input is never mutated: * redaction is a SERVING act. The runtime reads connectors through the * protocol's execution read, which applies no serving decoration, so a @@ -366,17 +380,24 @@ function undeclaredNestedKeysOf(item: Record): string[][] { return paths; } -/** Every credential position present in `item` that is withheld by removal, as item-relative paths. */ +/** + * Item 1: every key the item's instance-auth kind does not declare, as + * item-relative paths — withheld whatever its value, and not carried forward + * (see the module note). `[]` when `auth` is not an object (item 4 judges that). + */ +function undeclaredAuthKeysOf(item: Record): string[][] { + const auth = item.auth; + if (!isPlainRecord(auth)) return []; + const declared = declaredConnectorAuthKeys(auth); + return Object.keys(auth).filter((key) => !declared.has(key)).map((key) => ['auth', key]); +} + +/** Every other credential position present in `item` that is withheld by removal, as item-relative paths. */ function credentialPathsOf(item: Record): string[][] { const paths: string[][] = []; - const auth = item.auth; - if (isPlainRecord(auth)) { - const declared = declaredConnectorAuthKeys(auth); - for (const key of Object.keys(auth)) if (!declared.has(key)) paths.push(['auth', key]); - } else { - // Item 4: not an object, so withheld whole (a cleared form is skipped by the caller). - paths.push(['auth']); - } + // Item 4: `auth` that is not an object is withheld whole (a cleared form is + // skipped by the caller); an object `auth` is item 1's, judged key by key. + if (!isPlainRecord(item.auth)) paths.push(['auth']); const authentication = item.authentication; if (isPlainRecord(authentication)) { for (const key of Object.keys(authentication)) if (key !== 'type') paths.push(['authentication', key]); @@ -449,12 +470,14 @@ function credentialUrlRewritesOf( /** * The built-in `connector` redactor: withholds the positions the module note - * lists, names each in `redactedKeys` (dotted, sorted), and returns the input - * by reference when there is nothing to withhold. + * lists, names each in `redactedKeys` (dotted, sorted) and item 1's in + * `notCarriedForwardKeys` too, and returns the input by reference when there + * is nothing to withhold. */ export function redactConnectorItem(item: Record): MetadataRedactionResult { let served = item; const redactedKeys: string[] = []; + const notCarriedForwardKeys: string[] = []; // Items 7 and 8, first: an undeclared key is withheld whatever its value — // a cleared one included — because nothing can judge it. for (const path of [...undeclaredRootKeysOf(item), ...undeclaredNestedKeysOf(item)]) { @@ -463,6 +486,15 @@ export function redactConnectorItem(item: Record): MetadataReda served = withoutKeyAt(served, path); redactedKeys.push(path.join('.')); } + // Item 1: an undeclared `auth` key, whatever its value, withheld and not + // carried forward — a save of the served form drops it. + for (const path of undeclaredAuthKeysOf(item)) { + const { present } = readAt(served, path); + if (!present) continue; + served = withoutKeyAt(served, path); + redactedKeys.push(path.join('.')); + notCarriedForwardKeys.push(path.join('.')); + } for (const path of credentialPathsOf(item)) { const { present, value } = readAt(served, path); if (!present || isClearedForm(value)) continue; @@ -475,5 +507,10 @@ export function redactConnectorItem(item: Record): MetadataReda redactedKeys.push(path.join('.')); } if (redactedKeys.length === 0) return { item, redactedKeys: [] }; - return { item: served, redactedKeys: redactedKeys.sort() }; + return { + item: served, + redactedKeys: redactedKeys.sort(), + // Set only when it names a position: absent means none (the contract's own reading). + ...(notCarriedForwardKeys.length > 0 ? { notCarriedForwardKeys: notCarriedForwardKeys.sort() } : {}), + }; } diff --git a/packages/spec/src/kernel/metadata-type-redaction.test.ts b/packages/spec/src/kernel/metadata-type-redaction.test.ts index 278b7bae356..fa11a9fbecc 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.test.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.test.ts @@ -256,6 +256,52 @@ describe('FAIL-CLOSED: the connector redactor is a BUILT-IN as well', () => { }); }); +// [#28640 ②B, ruling 6566912252] `notCarriedForwardKeys` is a SUBSET of +// `redactedKeys`: a reader that knows only `redactedKeys` still withholds a +// not-carried-forward position, and every redactor that does not set it means +// "carry every withheld position forward". +describe('notCarriedForwardKeys — the withheld positions a save drops', () => { + const onlyUndeclaredAuth = { + name: 'vendor', + label: 'Vendor', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://vendor.example.com' }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: 'TEST_PLACEHOLDER_ONLY_AUTH' }, + }; + + it('the connector names its undeclared `auth` key in both lists', () => { + const result = getMetadataTypeRedactor('connector')!(onlyUndeclaredAuth); + expect(result.redactedKeys).toEqual(['auth.token']); + expect(result.notCarriedForwardKeys).toEqual(['auth.token']); + }); + + it('a row whose ONLY withheld position is not carried forward is still redacted by the stored-row seam (subset, never disjoint)', () => { + const row = { id: 'r1', type: 'connector', name: 'vendor', metadata: JSON.stringify(onlyUndeclaredAuth) }; + const served = redactStoredMetadataRow('sys_metadata', row); + expect(served.metadata).not.toMatch(/TEST_PLACEHOLDER_ONLY_AUTH/); + expect(JSON.parse(served.metadata as string).auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' }); + }); + + it('CONTROL: the datasource built-in and a derived redactor never set it — every withheld position is carried forward', () => { + const datasource = getMetadataTypeRedactor('datasource')!({ + name: 'warehouse', driver: 'postgres', config: { host: 'db', password: 'TEST_PLACEHOLDER_PW' }, ssl: { key: 'TEST_PLACEHOLDER_KEY' }, + }); + expect(datasource.redactedKeys.length).toBeGreaterThan(0); + expect(datasource).not.toHaveProperty('notCarriedForwardKeys'); + const webhook = getMetadataTypeRedactor('webhook')!({ + name: 'w', object: 'o', triggers: ['create'], url: 'https://hooks.example', secret: 'TEST_PLACEHOLDER_S', + }); + expect(webhook.redactedKeys).toEqual(['secret']); + expect(webhook).not.toHaveProperty('notCarriedForwardKeys'); + }); + + it('a registered redactor that does not set it type-checks unchanged (the member is optional)', () => { + const legacyShape: MetadataTypeRedactor = (item): MetadataRedactionResult => ({ item, redactedKeys: [] }); + expect(legacyShape({ a: 1 })).toEqual({ item: { a: 1 }, redactedKeys: [] }); + }); +}); + describe('absence is distinguishable from "nothing to redact" (the consumer contract of the per-type redaction hook)', () => { it('a type with no redactor answers undefined — a fact, not a failure', () => { expect(getMetadataTypeRedactor('object')).toBeUndefined(); diff --git a/packages/spec/src/kernel/metadata-type-redaction.ts b/packages/spec/src/kernel/metadata-type-redaction.ts index f10e5fbd7da..2b2a8021459 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.ts @@ -31,7 +31,11 @@ * as before. Redaction is a read-path SERVING act. * * The write-path carry-forward (a redacted-body PUT must not persist credential - * deletion) is also #8154's, deliberately not represented here. + * deletion) is also #8154's, and lives with the consumer. The one fact about + * it a redactor states is which withheld positions it must NOT carry forward + * (`MetadataRedactionResult.notCarriedForwardKeys`, #28640 ②B): the redactor is + * the only party that knows a position is dead, and the carry-forward reads it + * off the same result it already re-grafts from. * * ## Why `datasource` is a BUILT-IN, not a plugin registration * @@ -82,7 +86,24 @@ import { isStoredMetadataBodyObject, } from './stored-metadata-body-objects'; -/** What a {@link MetadataTypeRedactor} returns: the servable item, and what was withheld. */ +/** + * What a {@link MetadataTypeRedactor} returns: the servable item, what was + * withheld, and which of the withheld positions a save drops instead of + * keeping. + * + * ## What a caller does with each list + * + * - A READ exit serves `item` and treats `redactedKeys` as the whole answer + * to "was anything withheld?" — every withheld position is in it, whichever + * class it belongs to, so a reader that knows only `redactedKeys` withholds + * and reports exactly what it did before `notCarriedForwardKeys` existed. + * - A SAVE's carry-forward (the write-path inverse in + * `@objectstack/metadata-protocol`) re-grafts the stored value at each + * `redactedKeys` position the incoming body sends back as served — EXCEPT + * the positions `notCarriedForwardKeys` names. Those it never re-grafts, so + * a save of the served form drops them from the stored row, and the save + * names each one it dropped on its answer, so a drop at rest is loud. + */ export interface MetadataRedactionResult { /** The item with credential material removed. A NEW object — never the input mutated. */ item: Record; @@ -93,8 +114,27 @@ export interface MetadataRedactionResult { * {@link getMetadataTypeRedactor} answering `undefined`. Serving this beside * the item is the difference between a caller that knows a credential is * being withheld and one that infers it from an absence. + * + * Names EVERY withheld position, the {@link notCarriedForwardKeys} ones + * included: an empty list still means "nothing withheld". */ redactedKeys: string[]; + /** + * [#28640 ②B, ruling 6566912252] The positions among {@link redactedKeys} + * that are withheld AND NOT CARRIED FORWARD — the same dotted spelling, + * sorted, each one also in `redactedKeys`. A save of the served form drops + * the stored value at each of them instead of re-grafting it: the class is + * for a position nothing reads and no door accepts any more, where keeping + * the stored value only keeps cleartext at rest (a connector's undeclared + * `auth` keys, refused at the save door since the instance-auth shapes went + * strict). + * + * Optional, and absent means `[]`: every withheld position is carried + * forward, which is what every redactor that does not set it — the + * derivation, the `datasource` built-in, any registered one — has always + * meant. A redactor sets it only when it names at least one position. + */ + notCarriedForwardKeys?: string[]; } /**