From cf5bc5f60b0bd78ad4412169d018fcb775e96f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:37:52 +0000 Subject: [PATCH 1/7] feat(spec): a redactor can mark withheld positions a save does not carry forward MetadataRedactionResult gains an optional notCarriedForwardKeys, a subset of redactedKeys naming the withheld positions a save of the served form drops instead of re-grafting. The connector redactor marks every undeclared auth key (cleared values included, which the strict instance-auth shapes refuse whatever they hold). The metadata-protocol carry-forward skips those positions and the save names each one it dropped on the answer's advisories as a withheld-key-dropped warning. Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- ...edaction.connector-undeclared-keys.test.ts | 59 +++++++- .../src/metadata-redaction.ts | 80 +++++++++-- .../src/protocol-credential-carry-forward.ts | 67 ++++++++- .../src/protocol.metadata-redaction.test.ts | 128 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 24 +++- packages/spec/src/api/protocol.zod.ts | 9 +- .../connector-credential-redaction.test.ts | 75 ++++++++++ .../connector-credential-redaction.ts | 63 +++++++-- .../kernel/metadata-type-redaction.test.ts | 46 +++++++ .../src/kernel/metadata-type-redaction.ts | 44 +++++- 10 files changed, 557 insertions(+), 38 deletions(-) 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..c8b70462a00 100644 --- a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts +++ b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts @@ -45,8 +45,54 @@ 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 value sat at rest. + */ +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: + `the stored row held a value at '${dotted}', a position the read withholds and no save keeps ` + + '(no authoring door accepts it and nothing at runtime reads it), so this save removed it from the row at rest.', + 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 +117,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 +136,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..092ebe1dcf6 100644 --- a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts @@ -1680,3 +1680,131 @@ 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')]); + 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')]); + }); + + 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 8a8d5bfd311..05b26fdb27b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -372,6 +372,7 @@ import { // [#25467 cut 9] The credential carry-forward and the credential channel lookup. import { carryForwardRedactedCredentials, + droppedWithheldKeyNotices, credentialChannelFor, restoredCredentialPathsFor, storedBodyForCarryForward, @@ -9730,8 +9731,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), }; @@ -23082,7 +23085,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, @@ -23091,6 +23100,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 @@ -23211,7 +23225,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 59c1daeffb6..1f9890b5361 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -1227,7 +1227,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[]; } /** From a84fc4cf0814000b022576d9b6741c4a4755195d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:41:41 +0000 Subject: [PATCH 2/7] chore(changeset): withheld undeclared auth keys drain on save Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- .../28640-withheld-not-carried-forward.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/28640-withheld-not-carried-forward.md 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`. From deaff0636b9362a751ab0cdfc030639ff4362428 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:47:33 +0000 Subject: [PATCH 3/7] docs(references): regenerate the save answer's advisories description Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index feda4f59e89..69c396995db 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2867,7 +2867,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` From d8b38df0346b76a639776b78f3a018df194ed5da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:49:25 +0000 Subject: [PATCH 4/7] refactor(metadata-protocol): keep the carry-forward import list ordered Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 05b26fdb27b..0490eb1c4ae 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -372,8 +372,8 @@ import { // [#25467 cut 9] The credential carry-forward and the credential channel lookup. import { carryForwardRedactedCredentials, - droppedWithheldKeyNotices, credentialChannelFor, + droppedWithheldKeyNotices, restoredCredentialPathsFor, storedBodyForCarryForward, } from './protocol-credential-carry-forward.js'; From 04ba73c2207187c08cb3566eab228eab9658976d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:19:30 +0000 Subject: [PATCH 5/7] docs(spec): regenerate the protocol reference on the merged tree The merge took the branch's side of the generated protocol reference, which dropped main's retired form drawer keys; regenerating from the merged source restores them beside this branch's advisories description. Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 69c396995db..69bca8b769d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1916,8 +1916,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1995,8 +1995,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | From 9c87c39e8941f75ebf937cec05dc6f4a910f857a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 16:10:15 +0000 Subject: [PATCH 6/7] fix(metadata-protocol): word the dropped-key notice so it holds for a cleared value and a first save The notice said the stored row held a value that this save removed. A cleared value (`null`) held nothing, and a first save over the code layer writes a new row that never held the key. It now says this save did not carry the key forward and the row it wrote holds nothing there: true in all three cases, with no value in it. Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- .../src/protocol-credential-carry-forward.ts | 11 ++++++++--- .../src/protocol.metadata-redaction.test.ts | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts index c8b70462a00..91560041089 100644 --- a/packages/metadata-protocol/src/protocol-credential-carry-forward.ts +++ b/packages/metadata-protocol/src/protocol-credential-carry-forward.ts @@ -69,7 +69,12 @@ export const WITHHELD_KEY_DROPPED = 'withheld-key-dropped'; * 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 value sat at rest. + * 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; @@ -85,8 +90,8 @@ export function droppedWithheldKeyNotices(args: { where: `${singular} "${args.name}"`, path: root + dotted.split('.').map((segment) => (/^(0|[1-9][0-9]*)$/.test(segment) ? `[${segment}]` : `.${segment}`)).join(''), message: - `the stored row held a value at '${dotted}', a position the read withholds and no save keeps ` - + '(no authoring door accepts it and nothing at runtime reads it), so this save removed it from the row at rest.', + `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 ' diff --git a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts index 092ebe1dcf6..2d60e0616c6 100644 --- a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts @@ -1779,6 +1779,7 @@ describe('[#28640 ②B] a save of the served connector form drains an undeclared 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' }); }); @@ -1794,6 +1795,9 @@ describe('[#28640 ②B] a save of the served connector form drains an undeclared 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 () => { From 88387b3311c7510472e8794636ca541f4d09fb0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:18:27 +0000 Subject: [PATCH 7/7] docs(spec): regenerate the protocol reference on the merged tree The merge kept the branch's side of the generated protocol reference; regenerating from the merged source brings main's rows back beside this branch's advisories description. Claude-Session: https://claude.ai/code/session_01ADHkmS7ggoatyXpxRfHq4w Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 69bca8b769d..faebfb91a12 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1462,7 +1462,7 @@ This schema accepts one of the following structures: | **resettable** | `boolean` | optional | Whether the item can be reset to its packaged default — true iff it is artifact-backed, i.e. there is a baseline to reset TO. | | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | -| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged Actions): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | | **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | @@ -1877,7 +1877,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1956,7 +1956,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -2761,20 +2761,27 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' ## RegisterDeviceRequest +[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers + ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **token** | `string` | ✅ | Device push notification token | | **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform | -| **deviceId** | `string` | optional | Unique device identifier | +| **deviceId** | `string` | optional | Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; absent, the server mints one and answers it, and the client keeps it for later registrations and unregister | | **name** | `string` | optional | Device friendly name | +| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused | +| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID | +| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in | --- ## RegisterDeviceResponse +[PLANNED — not yet served] Answer to a device registration: the device id the client keeps + ### Properties | Property | Type | Required | Description | @@ -3019,6 +3026,8 @@ Uninstall package response ## UnregisterDeviceRequest +[PLANNED — not yet served] Unregister one of the signed-in user's push devices + ### Properties | Property | Type | Required | Description | @@ -3030,6 +3039,8 @@ Uninstall package response ## UnregisterDeviceResponse +[PLANNED — not yet served] Answer to a device unregistration + ### Properties | Property | Type | Required | Description |