diff --git a/.changeset/23487-retire-overdue-conversions.md b/.changeset/23487-retire-overdue-conversions.md new file mode 100644 index 00000000000..491933e3ecb --- /dev/null +++ b/.changeset/23487-retire-overdue-conversions.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': patch +--- + +feat(spec)!: seven overdue ADR-0087 D2 conversions leave the load path at 18 — six flow-node config aliases and a sharing rule's `accessLevel: 'full'` are refused with their rename at every door that judges a flow or a sharing rule + +Clause-②: no (narrowing) + +**BREAKING (authoring):** seven ADR-0087 D2 conversions whose one-major window had already run are now retired from the load path: `flow-node-crud-object-alias`, `flow-node-notify-config-aliases`, `flow-node-map-flow-alias`, `flow-node-subflow-flow-alias`, `flow-node-script-config-aliases` and `sharing-rule-access-level-full-to-edit` (protocol 17's window), and `flow-node-crud-filter-alias` (protocol 11's). The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites these spellings. An author who still writes one meets the refusal of the surface's own contract, and every one of those refusals is now the upgrade prescription: it names the canonical key and the release that refuses the old spelling. The flow-node prescriptions used to say "the conversion rewrites it at load". `accessLevel: 'full'` used to get zod's bare "Expected one of: read, edit"; `SharingLevel` now names `edit`. + +Migration: + +- CRUD node (`get_record` / `create_record` / `update_record` / `delete_record`) `config.object` → `config.objectName`, and `config.filters` → `config.filter`. Rename the key; the value is unchanged. +- `notify` node `config.to` → `recipients`, `config.subject` → `title`, `config.body` → `message`, `config.url` → `actionUrl`, and a nested `config.source: { object, id }` → the flat pair `sourceObject` / `sourceId`. Rename (or move) the key; the value is unchanged. +- `map` and `subflow` node `config.flow` → `config.flowName`. Rename the key; the value is unchanged. +- `script` node `config.functionName` → `config.function`, and `config.input` → `config.inputs`. Rename the key; the value is unchanged. (`connectorConfig.input` on a `connector_action` node is canonical; leave it.) +- Sharing rule `accessLevel: 'full'` → `accessLevel: 'edit'`. `full` never granted more than `edit`. +- If a config carries both spellings with different values, decide which one you mean, keep it under the canonical key, and delete the old one. + +Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. (`config.filters` belongs to protocol 11, below the migration floor, so `os migrate meta` does not list it: rename that one by hand.) + +Stored metadata needs no edit. The automation engine's flow rehydration and stored-row rehydration still replay the whole conversion chain, these seven entries included, so `sys_metadata` flows and sharing-rule rows written under an old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door. + +The metadata save door's flow fallback now reaches the canonicalizer's verdict for every retired flow shape. When no automation service is reachable (or its canonicalizer throws), `saveMetaItem` judges a flow body after replaying the full conversion chain the automation engine's canonicalizer replays, retired entries included (the default flip `flow-decision-mode-inclusive-explicit` excepted, as there). That covers the seven entries retired here and those retired earlier that walk a flow: `flow-inert-keys-removed` (`flow.active`, `flow.template`, `nodes[].outputSchema`, `errorHandling.fallbackNodeId`) and `flow-node-wait-timeout-keys-removed` (`waitEventConfig.timeoutMs` / `onTimeout`). On such a host a body carrying one of those older keys was refused 422 by its tombstone before; it is now healed for the verdict, which is what a host with an automation service already did. What is stored stays the body as submitted, so a host's verdict on a saved flow no longer depends on whether it runs an automation service. + +Eight more entries below 18 stay on the load path, each with the reason recorded in its docblock: `flow-node-http-callout-rename` and `page-kind-jsx-to-html` (the old value is still accepted by its schema), `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (the schema still declares and folds the deprecated key), `flow-node-wait-event-config-lift` and `flow-node-connector-config-lift` (the node's `config` has no contract to refuse the loose keys), `datasource-driver-mongo-to-mongodb` and `retry-policy-converged`. + + diff --git a/content/docs/references/automation/schemaless-node-config.mdx b/content/docs/references/automation/schemaless-node-config.mdx index 110c2787cd2..945d8a00ca3 100644 --- a/content/docs/references/automation/schemaless-node-config.mdx +++ b/content/docs/references/automation/schemaless-node-config.mdx @@ -82,8 +82,10 @@ form authoring keys nothing reads). Undeclared aliases are NOT part of these contracts: `subflow`'s historical `flow` spelling graduated into the ADR-0087 D2 conversion -`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only -ever sees `flowName`. +`flow-node-subflow-flow-alias` (the `map.flow` path), which left the load +path at 18 (#23487) and rewrites only stored flows, so an authored `flow` +meets `SUBFLOW_KEY_GUIDANCE` and the executor only ever sees +`flowName`. ## Unknown keys — closed as of #4001 批 9, and this class had NO other door diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index 8b33b6c13e9..ce5663f7c2b 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -61,7 +61,7 @@ * `page-component-visibility-to-visibleWhen` rewrites at load, against * `visibleWhen`, the canonical spelling that converts nothing. (The pair used * to differ in the header's `description` against `subtitle`; that conversion, - * `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and + * `page-header-subtitle-alias`, retired from the load path at protocol 18 and * the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the * CONVERSION and not on "a page is present" — measured on the pair before it * was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json` diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 11218d8556a..547aabafa7c 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -401,10 +401,13 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { * [#21982] On the fallback path — no canonicalizer resolved, or it threw — * the gate judges the body in its ADR-0087 D2-converted spelling, for the * verdict only. The flow parse refuses an undeclared config key on the - * builtin node types, so without the conversion a D2 spelling every other - * door converts first (`os validate`, `defineStack`, `registerFlow`) would - * be refused here, and only here: a verdict that depended on whether the - * host runs an automation service. What is STORED stays the raw body. + * builtin node types, so without the conversion a D2 spelling the + * canonicalizer converts first (`registerFlow`'s full chain, retired + * entries included) would be refused here, and only here: a verdict that + * depended on whether the host runs an automation service. So the fallback + * replays that same full chain (#23487) — for the seven flow spellings + * retired from the authoring funnel at 18 and for those retired before + * them alike. What is STORED stays the raw body. */ describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => { const scriptBody = (config: Record) => ({ @@ -430,6 +433,20 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' }); }); + it('an older-retired flow shape (`flow.active`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => { + // `flow-inert-keys-removed` (retired from the load path after 16.1.0) + // tombstones `active`. On a host with no automation service the + // fallback used to judge it in the authoring posture and refuse it + // 422; the service-backed path replays the entry and heals it. Since + // #23487 the fallback replays the same chain, so both hosts agree. + const { protocol, rows } = makeProtocol(new Map()); + + const result = await save(protocol, { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false }); + + expect(result.success).toBe(true); + expect(storedFlow(rows)!.body.active).toBe(false); + }); + it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => { const { protocol, rows } = makeProtocol(new Map()); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index fc4e72a6095..7c4ae5037e6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3247,6 +3247,22 @@ function isoFromValidDate(value: unknown): unknown { return value; } +/** + * ADR-0087 conversions the save door's flow fallback verdict (#21982) does + * not replay, by id — the same DEFAULT-FLIP refusal the automation engine's + * canonicalizer makes (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in + * `service-automation/src/engine.ts`), because the fallback stands in for that + * canonicalizer and must reach the same verdict on the same body. + * + * - `flow-decision-mode-inclusive-explicit` (#15429) rewrites an omitted + * decision `mode` into `'inclusive'`, which is sound only where "this body + * predates the flip" is a fact; a save never knows that, exactly as the + * engine's canonicalizer does not. + */ +const FLOW_FALLBACK_VERDICT_NOT_REPLAYED: readonly string[] = [ + 'flow-decision-mode-inclusive-explicit', +]; + // Lifecycle columns the engine always owns; the clone path drops them by NAME // so the insert re-stamps fresh values instead of copying the source's. Mirrors // record-validator's SKIP_FIELDS (system-injected, never author-supplied). @@ -22405,11 +22421,14 @@ export class ObjectStackProtocolImplementation implements // conversions applied (`applyConversionsToFlow`) — for the VERDICT // only: what is stored stays the raw request body, exactly as before. // The flow parse judges an undeclared config key on the builtin node - // types, so a D2 spelling the load path still rewrites (`filters`, + // types, so a D2 spelling the canonicalizer rewrites (`filters`, // script `functionName`, subflow `flow`, …) would otherwise be refused - // here and accepted at every converting door (`os validate`, - // `defineStack`, `registerFlow`): a verdict that depended on whether - // this host runs an automation service. No `reservedNodeTypes` (no + // here and accepted by `registerFlow`, which the canonicalizer is: a + // verdict that depended on whether this host runs an automation + // service. So the fallback replays the chain the canonicalizer replays + // — the FULL chain, retired entries included (#23487 retired those + // spellings from the authoring funnel, not from this seam), minus the + // same default flip ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). No `reservedNodeTypes` (no // engine here): a node-type rename's conflict guard keeps its one home // in the engine's canonicalizer, and this converted body is never // persisted. A key no conversion rewrites is judged exactly as written. @@ -22484,7 +22503,12 @@ export class ObjectStackProtocolImplementation implements canonicalized = true; } } - if (!canonicalized) flowGateVerdictBody = applyConversionsToFlow(request.item); + if (!canonicalized) { + flowGateVerdictBody = applyConversionsToFlow(request.item, { + includeRetired: true, + excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED, + }); + } } // Spec-conformance check: if a Zod schema is registered for this diff --git a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts index 3c796fc7d7f..c1c96965b71 100644 --- a/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts +++ b/packages/services/service-automation/src/builtin/crud-config-aliases.test.ts @@ -98,14 +98,19 @@ describe('CRUD config-key aliases: object→objectName + filters→filter (load- expect(calls[0].opts.where).toEqual({ id: 'L1' }); // filter preserved, not dropped }); - it('the same conversions run on the build/validate seam too (normalizeStackInput)', async () => { + it('the build/validate seam no longer converts them (retired at 18), and the rehydration seam still does', async () => { const engine = new AutomationEngine(silentLogger()); const { data, calls } = fakeData(); registerCrudNodes(engine, ctxWith(data, silentLogger())); + // Both conversions left the load path at 18 (#23487): the authoring + // funnel hands the aliases on as written, for the node contract's + // refusal to name the rename… const raw = getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' }); - const converted = (normalizeStackInput({ flows: [raw] }).flows as any[])[0]; - engine.registerFlow('gr', converted); + const authored = (normalizeStackInput({ flows: [raw] }).flows as any[])[0]; + expect(authored.nodes[1].config).toEqual({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' }); + // …while a flow that reaches the engine as data at rest still heals. + engine.registerFlow('gr', authored); await engine.execute('gr', { userId: 'u1' }); expect(calls[0].obj).toBe('crm_lead'); diff --git a/packages/spec/src/automation/builtin-node-config.test.ts b/packages/spec/src/automation/builtin-node-config.test.ts index 1423cbc9674..8fa88b57a6e 100644 --- a/packages/spec/src/automation/builtin-node-config.test.ts +++ b/packages/spec/src/automation/builtin-node-config.test.ts @@ -75,7 +75,9 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', () const message = unknownKeyMessage(schema, { ...base, object: 'lead' })!; expect(message).toContain(`this ${nodeType} node config`); expect(message).toContain('`objectName`'); - expect(message).toContain('flow-node-crud-object-alias'); + // The upgrade prescription since the conversion left the load path at 18. + expect(message).toContain('`object` was removed in @objectstack/spec 18'); + expect(message).toContain('Use `objectName`. Rename the key'); // The distance claim in the schema's comment, pinned: without the // curated entry this key would get NO suggestion at all. expect(message).not.toContain('`object` → '); @@ -90,7 +92,8 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', () '%s: prescribes `filter` for the retired `filters` spelling, and names the match-everything-write hazard', (_nodeType, schema, base) => { const message = unknownKeyMessage(schema, { ...base, filters: { status: 'stale' } })!; - expect(message).toContain('flow-node-crud-filter-alias'); + expect(message).toContain('`filters` was removed in @objectstack/spec 18'); + expect(message).toContain('Use `filter`. Rename the key'); expect(message).toContain('match-everything write'); expect(message, 'the prescription names the hazard, never a tracker id').not.toMatch(/#\d{3,5}/); }, @@ -101,7 +104,7 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', () // not declare. `create_record` is outside `flow-node-crud-filter-alias`'s // node-type set precisely because it has no filter. const message = unknownKeyMessage(CreateRecordConfigSchema, { objectName: 'task', filters: {} })!; - expect(message).not.toContain('flow-node-crud-filter-alias'); + expect(message).not.toContain('Use `filter`'); expect(message).not.toContain('`filter` (singular)'); }); @@ -412,7 +415,8 @@ describe('MapConfigSchema — an unknown key is refused, not stripped', () => { it('prescribes `flowName` for the undeclared `flow` fallback', () => { const message = unknownKeyMessage(MapConfigSchema, { collection: { dialect: 'cel', source: 'tasks' }, flowName: 'per_row', flow: 'ignored' })!; expect(message).toContain('this map node config'); - expect(message).toContain('flow-node-map-flow-alias'); + expect(message).toContain('`flow` was removed in @objectstack/spec 18'); + expect(message).toContain('Use `flowName`'); expect(message).toMatch(/delete/i); }); diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index b15a26d588f..83e9fea5026 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -130,18 +130,17 @@ const BUILTIN_NODE_CONFIG_HISTORY = /** * The two ADR-0087 D2 aliases every CRUD node shares. * - * Both are retired SPELLINGS rather than typos, and both are rewritten at load - * (`flow-node-crud-object-alias`, `flow-node-crud-filter-alias`). So each - * prescription has to answer two different readers, and since #4923 the split - * between them is exact: + * Both are retired SPELLINGS rather than typos. Their conversions + * (`flow-node-crud-object-alias`, `flow-node-crud-filter-alias`) left the load + * path at 18 (#23487), so each prescription is now the upgrade prescription, + * naming the release, and it still has to answer two different readers: * - * - whoever **parses this contract directly** (never went through the load - * path) wrote the retired spelling on its own → the fix is the rename; - * - whoever **came through the load path** still has the alias only because - * the conversion refused to resolve it, and it refuses in exactly one - * situation: the canonical key is ALSO present and carries a DIFFERENT - * value. An identical twin is deleted by the conversion now, so it can no - * longer reach this parse. + * - an **author** (every door that judges a flow, since the retirement) wrote + * the retired spelling on its own → the fix is the rename; + * - a **stored flow** reaches this parse after the rehydration seam replayed + * the retired conversion, and still has the alias only because the + * conversion refused to resolve it, which it does in exactly one situation: + * the canonical key is ALSO present and carries a DIFFERENT value (#4923). * * That second reader is why the prescription names BOTH keys and asks for a * decision rather than a deletion. Telling them "the canonical one already won, @@ -155,16 +154,17 @@ const BUILTIN_NODE_CONFIG_HISTORY = */ const CRUD_ALIAS_GUIDANCE = { object: - 'The object slot is `objectName`. `object` was the last tenant of the `readAliasedConfig` executor shim; it ' - + 'graduated into the ADR-0087 D2 conversion `flow-node-crud-object-alias`, which rewrites it at load. ' - + 'If `objectName` is also present, this node names two different objects and the conversion left both keys ' - + 'alone rather than picking for you — decide which object this node acts on, put it on `objectName`, ' - + 'and delete `object`.', + 'The CRUD node config key `object` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the last ' + + 'tenant of the `readAliasedConfig` executor shim, which the loader rewrote to `objectName` until this ' + + 'release. Use `objectName`. Rename the key; the value (the object name) is unchanged. If `objectName` is ' + + 'also present, this node names two different objects — decide which object it acts on, put it on ' + + '`objectName`, and delete `object`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', filters: - 'The match map is `filter` (singular). `filters` was a consumer-side executor fallback that graduated into the ' - + 'ADR-0087 D2 conversion `flow-node-crud-filter-alias`, which rewrites it at load; delete it once `filter` ' - + 'carries the pairs. If `filter` is also present, the two carry DIFFERENT match maps and the conversion kept ' - + 'both rather than choosing — reconcile them onto `filter`. Beware the half-migrated shape: an empty ' + 'The CRUD node config key `filters` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was a ' + + 'consumer-side executor fallback, which the loader rewrote to `filter` (singular) until this release. Use ' + + '`filter`. Rename the key; the value (the match map) is unchanged. If `filter` is also present, the two ' + + 'carry DIFFERENT match maps — reconcile them onto `filter`. Beware the half-migrated shape: an empty ' + '`filter` next to a populated `filters` is what made this alias dangerous enough to declare — it is a ' + 'match-everything write.', } as const; @@ -1319,8 +1319,10 @@ export type EndConfigParsed = z.infer; * * `flowName` and `collection` are execute-time required. The historical * undeclared `flow` alias is NOT part of this contract: the ADR-0087 D2 - * conversion `flow-node-map-flow-alias` rewrites it at load, so the executor - * only ever sees `flowName` (#4045 — the `notify.source` graduation path). + * conversion `flow-node-map-flow-alias` rewrote it at load for protocol 17's + * window (#4045 — the `notify.source` graduation path); retired from the load + * path at 18 (#23487), it rewrites only stored flows, so an authored `flow` + * meets the prescription below and the executor only ever sees `flowName`. * * `collection` is a `value` slot whose literal is a list (#19939, * {@link FlowCollectionSlotSchema}), the same contract a `loop` with a `body` @@ -1332,11 +1334,12 @@ export const MapConfigSchema = lazySchema(() => strictObject({ history: BUILTIN_NODE_CONFIG_HISTORY, guidance: { flow: - 'The per-item subflow is named by `flowName`. `flow` was an undeclared executor fallback no schema or form ' - + 'described; it graduated into the ADR-0087 D2 conversion `flow-node-map-flow-alias`, which rewrites ' - + 'it at load. If `flowName` is also present, the two name DIFFERENT subflows and the conversion kept both ' - + 'rather than picking one to run per item — decide which flow this is, put it on `flowName`, and ' - + 'delete `flow`.', + 'The `map` node config key `flow` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was an undeclared ' + + 'executor fallback no schema or form described, which the loader rewrote to `flowName` until this release. ' + + 'Use `flowName`. Rename the key; the value (the per-item subflow\'s name) is unchanged. If `flowName` is ' + + 'also present, the two name DIFFERENT subflows — decide which flow runs per item, put it on `flowName`, and ' + + 'delete `flow`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }, }, { /** diff --git a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts index dbbb58061ed..348efc7472f 100644 --- a/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts +++ b/packages/spec/src/automation/flow-builtin-node-config-keys.test.ts @@ -30,6 +30,7 @@ import { describe, expect, it } from 'vitest'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { applyConversionsToFlow } from '../conversions/apply'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; import { builtinNodeConfigKeysJudged, flowNodeConfigRefusals, getBuiltinNodeConfigContracts } from './flow-node-config-refusals'; @@ -210,7 +211,7 @@ describe('the remainder: every other builtin, refused at save', () => { expect(flowNodeConfigRefusals('loop', { collection: 5 })).toEqual([]); }); - it('a pre-conversion alias is converted first where the door converts, and refused at a direct parse', () => { + it('a retired alias is refused at a direct parse and at defineStack; only the rehydration seam converts it first', () => { const config = { objectName: 'task', filters: { id: { dialect: 'cel', source: 'recordId' } }, fields: { title: 'X' } }; expect(issuesOf(flowWith('update_record', config)).map((i) => i.path)).toEqual(['nodes.1.config.filters']); const stack = { @@ -218,7 +219,21 @@ describe('the remainder: every other builtin, refused at save', () => { objects: [{ name: 'als_task', label: 'Task', fields: { title: { type: 'text', label: 'Title' } } }], flows: [flowWith('update_record', { ...config, objectName: 'als_task' }, 'alias_flow')], }; - expect(() => defineStack(stack as never)).not.toThrow(); + // `flow-node-crud-filter-alias` left the load path at 18 (#23487), so the + // authoring funnel no longer converts first: the door refuses, at the key, + // with the upgrade prescription. + let refusal: unknown; + try { + defineStack(stack as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.filters']); + expect(issues[0]!.message).toContain('`filters` was removed in @objectstack/spec 18'); + // A stored flow is converted first, the full chain replayed, and parses clean. + expect(issuesOf(applyConversionsToFlow(flowWith('update_record', config), { includeRetired: true }))).toEqual([]); }); }); diff --git a/packages/spec/src/automation/flow.test.ts b/packages/spec/src/automation/flow.test.ts index bed027a672c..3c32b8e6adc 100644 --- a/packages/spec/src/automation/flow.test.ts +++ b/packages/spec/src/automation/flow.test.ts @@ -182,8 +182,8 @@ describe('FlowNodeSchema', () => { type: 'create_record', label: 'Create Account', // #5500 — was `object:`, the retired spelling the ADR-0087 D2 conversion - // `flow-node-crud-object-alias` rewrites at load (live through this major, - // retires at 18). A fixture is a teaching surface, so it spells the + // `flow-node-crud-object-alias` rewrote at load for protocol 17's window + // (retired from the load path at 18). A fixture is a teaching surface, so it spells the // canonical key the executor actually reads. config: { objectName: 'account', @@ -440,7 +440,7 @@ describe('FlowSchema', () => { label: 'Get Opportunity', // #4924 — was `{ object, recordId }`, and GetRecordConfigSchema declares // NEITHER: `object` is the retired spelling the ADR-0087 D2 conversion - // `flow-node-crud-object-alias` rewrites at load, and `recordId` is not a + // `flow-node-crud-object-alias` rewrote at load, and `recordId` is not a // key any CRUD executor has ever read. CRUD nodes address rows through // `filter` only, so the id goes in as a filter VALUE. `outputVariable` is // what actually binds the row to a variable — without it the downstream diff --git a/packages/spec/src/automation/io-node-config.test.ts b/packages/spec/src/automation/io-node-config.test.ts index aa28e0f5b2c..177afe66766 100644 --- a/packages/spec/src/automation/io-node-config.test.ts +++ b/packages/spec/src/automation/io-node-config.test.ts @@ -115,13 +115,14 @@ describe('NotifyConfigSchema — an unknown key is refused, not stripped', () => recipients: ['u1'], title: 'hi', [key]: value, }); expect(message).toContain(canonical); - // Both readings must be served: the ADR-0087 conversion rewrites this - // key at load, so a config that still carries it at PARSE time also - // carries the canonical key — and since #4923 it carries one holding a - // DIFFERENT value, because an identical twin is deleted by the - // conversion. Without this half the prescription ("rename it") is wrong - // for the population that actually reaches this error. - expect(message).toContain('flow-node-notify-config-aliases'); + // Both readings must be served. An author meets this refusal for the + // retired spelling itself — the conversion left the load path at 18 — + // so the prescription is the rename, naming the release. A stored flow + // reaches it after the rehydration seam replayed the conversion, so it + // still carries the alias only beside a canonical key holding a + // DIFFERENT value (#4923: an identical twin is deleted by the + // conversion). Without this half the rename is wrong for that population. + expect(message).toContain('was removed in @objectstack/spec 18'); expect(message).toMatch(/delete|reconcile/i); // The reconciliation reading has to name the OTHER key too, or the // author cannot see which two spellings disagree. diff --git a/packages/spec/src/automation/io-node-config.zod.ts b/packages/spec/src/automation/io-node-config.zod.ts index 9efdd55815f..820f4c4af7e 100644 --- a/packages/spec/src/automation/io-node-config.zod.ts +++ b/packages/spec/src/automation/io-node-config.zod.ts @@ -93,46 +93,50 @@ const IO_NODE_CONFIG_HISTORY = * `source` shape (#3796 / #4045). * * Each is a RETIRED SPELLING, not a typo, so a bare "did you mean" would - * under-serve it: `flow-node-notify-config-aliases` rewrites all five at load - * (including the `registerFlow` rehydration seam). Since #4923 that rewrite - * also DELETES a retired spelling that merely repeats the canonical key's - * value, which sharpens what a surviving one means: it survived because the - * canonical key is also present and says something DIFFERENT, and the + * under-serve it. `flow-node-notify-config-aliases` rewrote all five at load + * for protocol 17's window and left the load path at 18 (#23487), so each + * prescription is the upgrade prescription, naming the release. It answers + * two readers: an author, who wrote the retired spelling and owes the rename; + * and a stored flow, which reaches this parse after the rehydration seam + * replayed the conversion, and still carries the spelling only because the + * canonical key is also present and says something DIFFERENT (#4923) — the * conversion will not pick between two values the author wrote. - * - * So each prescription answers both readings — the rename, for whoever parses - * this contract directly, and a reconciliation naming BOTH keys, for whoever - * came through the load path. */ const NOTIFY_KEY_GUIDANCE: Readonly> = { to: - 'The recipient slot is `recipients`. `to` is the pre-17 spelling, rewritten at load by the ADR-0087 D2 ' - + 'conversion `flow-node-notify-config-aliases` — so if `recipients` is also present, the two name DIFFERENT ' - + 'recipients and the conversion kept both rather than choosing who gets notified. Decide the ' - + 'recipients, put them on `recipients`, and delete `to`.', + 'The `notify` node config key `to` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the email-idiom ' + + 'spelling the loader rewrote to `recipients` until this release. Use `recipients`. Rename the key; the value ' + + '(who gets notified) is unchanged. If `recipients` is also present, the two name DIFFERENT recipients — ' + + 'decide who gets notified, put them on `recipients`, and delete `to`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', subject: - 'The heading slot is `title`. `subject` is the pre-17 spelling rewritten at load by ' - + '`flow-node-notify-config-aliases`; delete it once `title` carries the text. If `title` is also present with ' - + 'a DIFFERENT value, the conversion kept both rather than choosing — reconcile them onto `title`.', + 'The `notify` node config key `subject` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + + 'email-idiom spelling the loader rewrote to `title` until this release. Use `title`. Rename the key; the ' + + 'value (the heading text) is unchanged. If `title` is also present with a DIFFERENT value, reconcile them ' + + 'onto `title`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', body: - 'The body slot is `message`. `body` is the pre-17 spelling rewritten at load by ' - + '`flow-node-notify-config-aliases`; delete it once `message` carries the text. If `message` is also present ' - + 'with a DIFFERENT value, the conversion kept both rather than choosing — reconcile them onto `message`. ' - + '(`body` IS canonical on an `http` node — the key is wrong only here.)', + 'The `notify` node config key `body` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + + 'email-idiom spelling the loader rewrote to `message` until this release. Use `message`. Rename the key; ' + + 'the value (the body text) is unchanged. If `message` is also present with a DIFFERENT value, reconcile ' + + 'them onto `message`. (`body` IS canonical on an `http` node — the key is wrong only here.) ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', url: - 'The click-through slot is `actionUrl`. It was renamed at 17 because `url` elsewhere on the platform means ' - + '"HTTP endpoint to call" (`http` node, webhooks), a different concept from an in-app click target. ' - + '`flow-node-notify-config-aliases` rewrites it at load; delete it once `actionUrl` carries the link. If ' - + '`actionUrl` is also present with a DIFFERENT link, the conversion kept both rather than choosing where the ' - + 'notification points — reconcile them onto `actionUrl`.', + 'The `notify` node config key `url` was removed in @objectstack/spec 18 (ADR-0087 D2) — `url` elsewhere on ' + + 'the platform means "HTTP endpoint to call" (`http` node, webhooks), a different concept from an in-app ' + + 'click target, and the loader rewrote it to `actionUrl` until this release. Use `actionUrl`. Rename the ' + + 'key; the value (the click-through link) is unchanged. If `actionUrl` is also present with a DIFFERENT ' + + 'link, reconcile them onto `actionUrl`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', source: - 'The click-through target is the flat PAIR `sourceObject` + `sourceId`, never a nested `source: { object, id }`. ' - + '`flow-node-notify-config-aliases` lifts the nested shape at load and drops it once every part is accounted ' - + 'for, so a surviving `source` means a part of it holds a DIFFERENT value from the flat `sourceObject` / ' - + '`sourceId` already in that slot, and the conversion declined to pick — reconcile onto the flat pair ' - + 'and delete `source`. ' + 'The nested `notify` node config `source: { object, id }` was removed in @objectstack/spec 18 (ADR-0087 D2) — ' + + 'the click-through target is the flat PAIR `sourceObject` + `sourceId`, which the loader lifted the nested ' + + 'shape onto until this release. Use `sourceObject` and `sourceId`. Move `source.object` to `sourceObject` ' + + 'and `source.id` to `sourceId`; the values are unchanged. If the flat pair is also present with a DIFFERENT ' + + 'value, reconcile onto the flat pair and delete `source`. ' + 'Note the pair only takes effect together: a half-specified target is dropped so the inbox never renders a ' - + 'dead link.', + + 'dead link. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }; /** @@ -525,8 +529,11 @@ function notifyLiteralRefusals(cfg: { * tolerates (drops) the half-specified shape rather than rejecting it. * - The historical aliases (`to`/`subject`/`body`/`url`, nested * `source: { object, id }`) are NOT part of this contract: the ADR-0087 D2 - * conversion `flow-node-notify-config-aliases` rewrites them at load, so the - * executor only ever sees the canonical keys below (#3796, #4045). + * conversion `flow-node-notify-config-aliases` rewrote them at load for + * protocol 17's window (#3796, #4045) and, retired from the load path at 18 + * (#23487), rewrites only stored flows, so an authored alias meets + * {@link NOTIFY_KEY_GUIDANCE} and the executor only ever sees the canonical + * keys below. */ export const NotifyConfigSchema = lazySchema(() => strictObject({ surface: 'this notify node config', diff --git a/packages/spec/src/automation/schemaless-node-config.test.ts b/packages/spec/src/automation/schemaless-node-config.test.ts index 19fd6e377bd..8ccce8c314d 100644 --- a/packages/spec/src/automation/schemaless-node-config.test.ts +++ b/packages/spec/src/automation/schemaless-node-config.test.ts @@ -165,11 +165,12 @@ describe('unknown keys — refused, not stripped, and this class had no other ga ['functionName', 'score_lead', '`function`'], ['input', { leadId: '1' }, '`inputs`'], ] as ReadonlyArray<[string, unknown, string]>)( - 'script: the retired `%s` alias gets its conversion named, not just a rename', + 'script: the retired `%s` alias gets the upgrade prescription naming its release, not just a rename', (key, value, canonical) => { const message = unknownKeyMessage(ScriptConfigSchema, { function: 'f', [key]: value })!; expect(message).toContain(canonical); - expect(message).toContain('flow-node-script-config-aliases'); + expect(message).toContain('was removed in @objectstack/spec 18'); + expect(message).toContain(`Use ${canonical}. Rename the key`); }, ); @@ -198,7 +199,7 @@ describe('unknown keys — refused, not stripped, and this class had no other ga it('subflow: prescribes `flowName`, and points `timeoutMs` at the node it belongs on', () => { expect(unknownKeyMessage(SubflowConfigSchema, { flowName: 'audit_flow', flow: 'ignored' })) - .toContain('flow-node-subflow-flow-alias'); + .toContain('`flow` was removed in @objectstack/spec 18 (ADR-0087 D2)'); const timeout = unknownKeyMessage(SubflowConfigSchema, { flowName: 'audit_flow', timeoutMs: 30000 })!; expect(timeout).toContain('FlowNodeSchema.timeoutMs'); }); diff --git a/packages/spec/src/automation/schemaless-node-config.zod.ts b/packages/spec/src/automation/schemaless-node-config.zod.ts index 635efebc0e9..d2a1ab6ab08 100644 --- a/packages/spec/src/automation/schemaless-node-config.zod.ts +++ b/packages/spec/src/automation/schemaless-node-config.zod.ts @@ -79,8 +79,10 @@ * * Undeclared aliases are NOT part of these contracts: `subflow`'s historical * `flow` spelling graduated into the ADR-0087 D2 conversion - * `flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only - * ever sees `flowName`. + * `flow-node-subflow-flow-alias` (the `map.flow` path), which left the load + * path at 18 (#23487) and rewrites only stored flows, so an authored `flow` + * meets {@link SUBFLOW_KEY_GUIDANCE} and the executor only ever sees + * `flowName`. * * ## Unknown keys — closed as of #4001 批 9, and this class had NO other door * @@ -138,11 +140,13 @@ const SCHEMALESS_NODE_CONFIG_HISTORY = /** * `script` prescriptions for the two ADR-0087 D2 aliases (#3796). * - * `functionName` and `input` are retired SPELLINGS that - * `flow-node-script-config-aliases` rewrites at load, so — like the notify - * family — a config still carrying one at parse time carries the canonical key - * too, and since #4923 it carries a canonical key holding a DIFFERENT value - * (an identical twin is deleted by the conversion). `input` earns its entry + * `functionName` and `input` are retired SPELLINGS. Their conversion, + * `flow-node-script-config-aliases`, left the load path at 18 (#23487), so — + * like the notify family — each prescription is the upgrade prescription, + * naming the release, for an author; a stored flow reaches this parse after + * the rehydration seam replayed the conversion, and still carries one only + * beside a canonical key holding a DIFFERENT value (#4923; an identical twin + * is deleted by the conversion). `input` earns its entry * twice over: edit distance would suggest `inputs` without ever saying that * `input` is *canonical* on `connector_action`'s `connectorConfig`, which is * where the spelling leaked in from and where it must NOT be changed. @@ -154,26 +158,29 @@ const SCHEMALESS_NODE_CONFIG_HISTORY = */ const SCRIPT_KEY_GUIDANCE: Readonly> = { functionName: - 'The callable reference is `function`. `functionName` was the AI/template-emitted alias, rewritten at ' - + 'load by the ADR-0087 D2 conversion `flow-node-script-config-aliases`. If `function` is also present, the two ' - + 'name DIFFERENT callables and the conversion kept both rather than picking which one runs — decide ' - + 'which it is, put it on `function`, and delete `functionName`.', + 'The `script` node config key `functionName` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was the ' + + 'AI/template-emitted alias the loader rewrote to `function` until this release. Use `function`. Rename the ' + + 'key; the value (the callable reference) is unchanged. If `function` is also present, the two name DIFFERENT ' + + 'callables — decide which one runs, put it on `function`, and delete `functionName`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', input: - 'The input map on a `script` node is `inputs` (plural). The singular `input` leaked in from ' - + "`connector_action`, where `connectorConfig.input` is a DIFFERENT and canonical surface — do not \"fix\" that " - + 'one. `flow-node-script-config-aliases` rewrites this key at load; delete it once `inputs` carries the values. ' - + 'If `inputs` is also present with DIFFERENT values, the conversion kept both rather than choosing — ' - + 'reconcile them onto `inputs`.', + 'The `script` node config key `input` was removed in @objectstack/spec 18 (ADR-0087 D2) — the singular ' + + "spelling leaked in from `connector_action`, where `connectorConfig.input` is a DIFFERENT and canonical " + + 'surface (do not "fix" that one), and the loader rewrote it to `inputs` until this release. Use `inputs`. ' + + 'Rename the key; the value (the input map) is unchanged. If `inputs` is also present with DIFFERENT values, ' + + 'reconcile them onto `inputs`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', }; /** `subflow` prescriptions — one retired spelling, one wrong layer. */ const SUBFLOW_KEY_GUIDANCE: Readonly> = { flow: - 'The invoked flow is named by `flowName`. `flow` was an undeclared executor fallback that no schema or form ' - + 'ever described; it graduated into the ADR-0087 D2 conversion `flow-node-subflow-flow-alias`, which ' - + 'rewrites it at load. If `flowName` is also present, the two name DIFFERENT flows and the conversion kept ' - + 'both rather than picking which one this step invokes — decide which it is, put it on `flowName`, ' - + 'and delete `flow`.', + 'The `subflow` node config key `flow` was removed in @objectstack/spec 18 (ADR-0087 D2) — it was an undeclared ' + + 'executor fallback no schema or form ever described, which the loader rewrote to `flowName` until this ' + + 'release. Use `flowName`. Rename the key; the value (the invoked flow\'s name) is unchanged. If `flowName` ' + + 'is also present, the two name DIFFERENT flows — decide which one this step invokes, put it on `flowName`, ' + + 'and delete `flow`. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', timeoutMs: "A subflow step's timeout is the engine's per-node guard, so it belongs on the NODE, not in its config: " + '`{ id, type: "subflow", timeoutMs: 30000, config: { … } }`. `FlowNodeSchema.timeoutMs` is the declared key.', @@ -386,9 +393,10 @@ export type ScriptConfigParsed = z.infer; * guard (wrong metadata — a rerun cannot supply it) instead of failing through * a hand-written check. The historical undeclared `flow` alias is NOT part of * this contract: the - * ADR-0087 D2 conversion `flow-node-subflow-flow-alias` rewrites it at load - * (#4278 — the `map.flow` graduation path), so the executor only ever sees - * `flowName`. The node-level `timeoutMs` lives on {@link FlowNodeSchema}, not + * ADR-0087 D2 conversion `flow-node-subflow-flow-alias` rewrote it at load for + * protocol 17's window (#4278 — the `map.flow` graduation path) and, retired + * from the load path at 18 (#23487), rewrites only stored flows, so the + * executor only ever sees `flowName`. The node-level `timeoutMs` lives on {@link FlowNodeSchema}, not * here — a subflow step's timeout is the engine's per-node guard. */ export const SubflowConfigSchema = lazySchema(() => strictObject({ diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index f8f37ce9e3f..09b5da4a4be 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -14,6 +14,7 @@ import { import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; import { ElementButtonPropsSchema, PageHeaderProps, PageTabsProps } from '../ui/component.zod.js'; import { PageSchema } from '../ui/page.zod.js'; +import { defineStack } from '../stack.zod.js'; import { applyMetaMigrations } from '../migrations/chain.js'; import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; @@ -177,6 +178,10 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + // `flow-node-crud-filter-alias` and `flow-node-crud-object-alias` left the + // load path at 18 (#23487): the rewrite cases below run the data-at-rest + // posture (`includeRetired`) — the replay stored flows still get — and the + // retirement itself is pinned in the load-path retirement block. describe('flow-node-crud-filter-alias (PD #12 retirement)', () => { it('renames config.filters → config.filter only for CRUD node types', () => { const { stack, notices } = collectConversionNotices({ @@ -190,7 +195,7 @@ describe('conversion layer (ADR-0087 D2)', () => { ], }, ], - }); + }, { includeRetired: true }); const nodes = (stack.flows as any[])[0].nodes; expect(nodes[0].config).toEqual({ objectName: 'lead', filter: { x: 1 } }); expect(nodes[1].config).toEqual({ filters: { y: 2 } }); @@ -211,7 +216,7 @@ describe('conversion layer (ADR-0087 D2)', () => { ], }, ], - }); + }, { includeRetired: true }); const node = (stack.flows as any[])[0].nodes[0]; expect(node.config.filter).toEqual({ keep: true }); // Two different match maps under two names is the ambiguous half of the @@ -247,6 +252,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('deletes an alias whose value EQUALS the canonical key, and emits a notice', () => { const { stack, notices } = collectConversionNotices( crudFlow({ objectName: 'task', object: 'task' }), + { includeRetired: true }, ); expect(configOf(stack)).toEqual({ objectName: 'task' }); expect(configOf(stack)).not.toHaveProperty('object'); @@ -274,15 +280,15 @@ describe('conversion layer (ADR-0087 D2)', () => { ], }, ], - }); + }, { includeRetired: true }); expect(configOf(stack)).toEqual({ filter: { status: 'stale' } }); expect(notices).toHaveLength(1); }); it('is idempotent in shape AND notices — the deduped result replays to itself', () => { - const once = collectConversionNotices(crudFlow({ objectName: 'task', object: 'task' })); + const once = collectConversionNotices(crudFlow({ objectName: 'task', object: 'task' }), { includeRetired: true }); expect(once.notices).toHaveLength(1); - const twice = collectConversionNotices(once.stack); + const twice = collectConversionNotices(once.stack, { includeRetired: true }); expect(twice.stack).toEqual(once.stack); expect(twice.notices).toHaveLength(0); }); @@ -297,7 +303,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('keeps BOTH spellings when the values differ — the upgrade tool does not choose', () => { const before = crudFlow({ objectName: 'task', object: 'ticket' }); - const { stack, notices } = collectConversionNotices(structuredClone(before)); + const { stack, notices } = collectConversionNotices(structuredClone(before), { includeRetired: true }); expect(stack).toEqual(before); expect(configOf(stack)).toEqual({ objectName: 'task', object: 'ticket' }); expect(notices).toHaveLength(0); @@ -306,6 +312,7 @@ describe('conversion layer (ADR-0087 D2)', () => { it('judges each pair on its own value — one twin dedupes while its neighbour survives', () => { const { stack, notices } = collectConversionNotices( crudFlow({ objectName: 'task', object: 'task', filter: { a: 1 }, filters: { a: 2 } }), + { includeRetired: true }, ); expect(configOf(stack)).toEqual({ objectName: 'task', filter: { a: 1 }, filters: { a: 2 } }); expect(notices).toHaveLength(1); @@ -382,6 +389,65 @@ describe('conversion layer (ADR-0087 D2)', () => { } }); + /** + * The family closed at 18 (#23487): every entry whose window ran out + * before 18 has a verdict. Seven left the load path (with #23421's two, + * nine of the family); the eight below stay, each with the reason written + * into its own docblock and what would have to change for it to retire. + * A new entry below 18 that is neither retired nor listed here is an + * entry nobody judged. + */ + it('every entry whose window ran out before 18 is retired or kept by name', () => { + const kept = ALL_CONVERSIONS.filter((c) => c.toMajor < 18 && !c.retiredFromLoadPath).map((c) => c.id).sort(); + expect(kept).toEqual([ + 'datasource-driver-mongo-to-mongodb', + 'flow-node-connector-config-lift', + 'flow-node-http-callout-rename', + 'flow-node-wait-event-config-lift', + 'page-component-visibility-to-visibleWhen', + 'page-kind-jsx-to-html', + 'retry-policy-converged', + 'view-visibleOn-to-visibleWhen', + ]); + for (const id of [ + 'flow-node-crud-filter-alias', + 'flow-node-crud-object-alias', + 'flow-node-notify-config-aliases', + 'flow-node-map-flow-alias', + 'flow-node-subflow-flow-alias', + 'flow-node-script-config-aliases', + 'sharing-rule-access-level-full-to-edit', + ]) { + expect(ALL_CONVERSIONS.find((c) => c.id === id), id).toMatchObject({ retiredFromLoadPath: true, retiredAfter: '17.7.0' }); + } + }); + + it('a retired flow-node alias reaches defineStack as authored and is refused naming the rename and the release', () => { + const authored = { + flows: [{ + name: 'f', + label: 'F', + type: 'autolaunched', + nodes: [ + { id: 's', type: 'start', label: 'S' }, + { id: 'g', type: 'get_record', label: 'G', config: { objectName: 'lead', object: 'lead' } }, + ], + edges: [], + }], + }; + let refusal: unknown; + try { + defineStack(authored as never); + } catch (error) { + refusal = error; + } + expect(refusal).toMatchObject({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + const issues = (refusal as { issues: { path: PropertyKey[]; message: string }[] }).issues; + expect(issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.object']); + expect(issues[0]!.message).toContain('`object` was removed in @objectstack/spec 18'); + expect(issues[0]!.message).toContain('Use `objectName`'); + }); + it('a live-window conversion still applies at load (protocol-15 aliases)', () => { const { stack, notices } = collectConversionNotices({ pages: [ diff --git a/packages/spec/src/conversions/region-walk.test.ts b/packages/spec/src/conversions/region-walk.test.ts index 57be91e482b..e7bafe4eb71 100644 --- a/packages/spec/src/conversions/region-walk.test.ts +++ b/packages/spec/src/conversions/region-walk.test.ts @@ -121,6 +121,8 @@ describe('conversions reach nodes inside structured regions', () => { // `filters` → `filter` is the highest-stakes entry in the table: the // `delete_record` executor reads the canonical key, so leaving the alias // unconverted leaves it with NO filter at all. It has to reach a loop body. + // Both CRUD aliases left the load path at 18 (#23487), so the walk is + // exercised in the posture that still replays them: data at rest. const { stack } = collectConversionNotices({ flows: [{ name: 'purge', @@ -135,7 +137,7 @@ describe('conversions reach nodes inside structured regions', () => { }, }], }], - }); + }, { includeRetired: true }); expect((stack.flows as any[])[0].nodes[0].config.body.nodes[0].config) .toEqual({ objectName: 'lead', filter: { status: 'stale' } }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 1a3cfd1f3ff..dfc823a26a1 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -54,6 +54,14 @@ import { FLOW_REGION_SLOTS_BY_TYPE } from '../automation/region-slots.js'; * unified to the single canonical `http` node (see * `services/service-automation/src/builtin/http-nodes.ts`). A pure enum * re-spelling — losslessly convertible. + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** `flow.node.type` + * is an open namespace (ADR-0018), so no door refuses `http_request` / + * `http_call` / `webhook`: retired, this entry would leave an authored node + * silently carrying a type no builtin executor owns, where today it converts + * with a notice. It can retire once the authoring doors refuse those three + * reserved names with the rename to `http`, which is a decision about + * reserving retired official names inside an open namespace. */ const flowNodeHttpRename: MetadataConversion = { id: 'flow-node-http-callout-rename', @@ -123,6 +131,13 @@ const flowNodeHttpRename: MetadataConversion = { * `'jsx'` is a documented deprecated alias of the canonical `'html'` page kind * (ADR-0080; see `spec/src/ui/page.zod.ts`). The `source` semantics are * identical, so the rename is lossless. + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** `'jsx'` is + * still a declared member of `PageSchema.kind` and of `COMPILED_PAGE_KINDS`, + * which the save-time compile reads, so retired, this entry would leave the + * value silently accepted at every door rather than refused. It can retire + * once the value itself is retired from the enum (an `enumWithRetiredValues` + * prescription naming `html`), together with the paths that still branch on it. */ const pageKindJsxToHtml: MetadataConversion = { id: 'page-kind-jsx-to-html', @@ -157,10 +172,23 @@ const pageKindJsxToHtml: MetadataConversion = { * dialect tolerance is promoted here into one declared, expiring conversion and * the executor fallback is deleted: the load path now hands the executor the * canonical `filter` key, so the executor reads `cfg.filter` directly. + * + * **Retired from the load path at 18** — its window (protocol 11) ran long + * ago, and the entry stayed live only because nothing marked it (#23487). The + * authoring funnel no longer rewrites `filters`, so an author meets the CRUD + * node's strict config contract, whose refusal names `filter` and this release + * (`CRUD_ALIAS_GUIDANCE`), at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation engine's + * flow rehydration and stored-row rehydration (`applyConversionsToStoredItem`) + * replay the full chain, this entry included, and so does the + * artifact-ingestion door inside its window. `os migrate meta` does not reach + * it: protocol 11 is below `MIGRATION_SUPPORT_FLOOR`. */ const flowNodeFilterAlias: MetadataConversion = { id: 'flow-node-crud-filter-alias', toMajor: 11, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.config.filter', summary: "CRUD flow-node config key 'filters' → 'filter'", apply(stack, emit) { @@ -468,6 +496,13 @@ function renameVisibilityAlias( * key (the zod schemas also normalize it at parse — this entry makes the * acceptance *declared, loud, and expiring* per ADR-0087 D2, and will * graduate into the step-16 chain when the alias is removed). + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** `visibleOn` is + * still a declared `[DEPRECATED]` key on the form field and section schemas + * (`view.zod.ts`), which fold it into `visibleWhen` at parse, so retiring this + * entry alone changes nothing an author can observe: the old spelling would + * still be accepted without a word. It retires with the alias itself, once + * those schemas tombstone `visibleOn` (`retiredKey`, naming `visibleWhen`). */ const viewVisibleOnToVisibleWhen: MetadataConversion = { id: 'view-visibleOn-to-visibleWhen', @@ -568,6 +603,12 @@ const viewVisibleOnToVisibleWhen: MetadataConversion = { * (#6776, #6775). **Live window**, same terms as * {@link viewVisibleOnToVisibleWhen}. (An AI agent's `visibility` property is * a different, unrelated surface and is not touched.) + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** `visibility` is + * still a declared `[DEPRECATED]` key on `PageComponentSchema` (`page.zod.ts`), + * folded into `visibleWhen` at parse, so retiring this entry alone would leave + * the old spelling silently accepted. It retires with the alias itself, once + * that schema tombstones `visibility` (`retiredKey`, naming `visibleWhen`). */ const pageComponentVisibilityToVisibleWhen: MetadataConversion = { id: 'page-component-visibility-to-visibleWhen', @@ -863,20 +904,30 @@ const agentToolsToSkills: MetadataConversion = { * old and new shapes are already behaviourally identical, so the loader can * convert with zero consumer action. * - * **Live window** — deliberately unlike its three step-17 siblings, which are - * `retiredFromLoadPath` because each was an already-deprecated key whose schema - * now tombstones it with a fix-it error. `full` carried no prior deprecation and - * a removed enum VALUE yields only a generic zod message, so it gets the - * ADR-0087 D2 default instead: the protocol-17 loader accepts it for one major - * (this entry runs at `normalizeStackInput`, *before* the enum rejects it) and - * retires at 18. Accepting it is zero-risk precisely because the rewrite is - * behaviour-preserving. The runtime counterpart for already-persisted rows lives - * in `plugin-sharing` (grant-time normalisation + a boot backfill over - * `sys_sharing_rule` / `sys_record_share`). + * It shipped as a **live window**, deliberately unlike its three step-17 + * siblings, which were `retiredFromLoadPath` from the start because each was an + * already-deprecated key whose schema tombstones it with a fix-it error. `full` + * carried no prior deprecation and a removed enum VALUE yields only a generic + * zod message, so it got the ADR-0087 D2 default instead: the protocol-17 + * loader accepted it for one major (this entry ran at `normalizeStackInput`, + * *before* the enum rejects it). The runtime counterpart for already-persisted + * rows lives in `plugin-sharing` (grant-time normalisation + a boot backfill + * over `sys_sharing_rule` / `sys_record_share`). + * + * **Retired from the load path at 18** — that window has run (#23487). The + * generic message is gone with it: `SharingLevel` is an + * `enumWithRetiredValues` whose `full` prescription names `edit` and this + * release, so that refusal is what an author meets at every door that judges + * a sharing rule (`os validate` / `os build` / `os lint`). Stored rows keep + * healing: stored-row rehydration (`applyConversionsToStoredItem`) replays the + * full chain, this entry included, and so do the artifact-ingestion door + * (inside its window) and `os migrate meta --from 16`. */ const sharingRuleAccessLevelFullToEdit: MetadataConversion = { id: 'sharing-rule-access-level-full-to-edit', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'sharingRule.accessLevel', summary: "sharing-rule accessLevel 'full' → 'edit' (`full` never granted more than `edit`; a sharing rule grants read or edit, while delete and transfer come from object permissions and ownership)", apply(stack, emit) { @@ -944,12 +995,23 @@ function renameFlowConfigAliases( * {@link flowNodeFilterAlias} pioneered: the alias is rewritten to the * canonical key at load — including the `AutomationEngine.registerFlow` * rehydration seam — so the CRUD executors read `cfg.objectName` directly and - * the shim is deleted. **Live window**: stored flows authored with `object` - * keep loading through this major; retires at 18. + * the shim is deleted. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23487). The authoring funnel no longer rewrites the old + * spelling, so an author meets the CRUD node's strict config contract (`CRUD_ALIAS_GUIDANCE`), whose refusal names the rename and + * this release, at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) replay the full chain, this entry + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeCrudObjectAlias: MetadataConversion = { id: 'flow-node-crud-object-alias', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.config.objectName', summary: "CRUD flow-node config key 'object' → 'objectName' (the last alias in the executors' `readAliasedConfig` shim graduates into this layer, and the shim is deleted)", apply(stack, emit) { @@ -1067,11 +1129,22 @@ function liftNotifySourceShape(stack: Dict, emit: Emit): Dict { * platform means "HTTP endpoint to call" (`http` node, webhooks), a different * concept from this in-app click-through target. The executor precedence * already put `actionUrl` first, so the choice is behaviour-preserving. - * **Live window**; retires at 18. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23487). The authoring funnel no longer rewrites the old + * spelling, so an author meets the `notify` contract (`NOTIFY_KEY_GUIDANCE`), whose refusal names the rename and + * this release, at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) replay the full chain, this entry + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeNotifyConfigAliases: MetadataConversion = { id: 'flow-node-notify-config-aliases', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.notify.config', summary: "notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (executor `??` fallbacks graduated into this layer; `actionUrl` is canonical because the notification chain downstream already uses it), " + @@ -1278,7 +1351,16 @@ function liftWaitEventConfig(stack: Dict, emit: Emit): Dict { /** * Wait flow-node loose `config` keys → the declared `waitEventConfig` sibling * (protocol 17, #4045). See {@link liftWaitEventConfig} for the precedence rules - * and why `eventType` is defaulted. **Live window**; retires at 18. + * and why `eventType` is defaulted. + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** `wait` has no + * `config` contract (its contract is the sibling block), so retired, a loose + * key beside a declared `waitEventConfig` would be silently ignored, and a node + * with only loose keys would meet the missing-block refusal, which does not + * name the move. Refusing the loose keys under `config` would also refuse + * stored flows whose loose key the block shadows, because the rehydration seam + * parses the same schema and this entry leaves a shadowed key in place; it can + * retire once that shadowed shape has a ruling. */ const flowNodeWaitEventConfigLift: MetadataConversion = { id: 'flow-node-wait-event-config-lift', @@ -1355,11 +1437,23 @@ const flowNodeWaitEventConfigLift: MetadataConversion = { * executor for the #4045 reconciliation. * * A pure key rename with unchanged values, so the pair mechanism expresses it - * directly. **Live window**; retires at 18. + * directly. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23487). The authoring funnel no longer rewrites the old + * spelling, so an author meets the `map` contract (`MapConfigSchema`), whose refusal names the rename and + * this release, at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) replay the full chain, this entry + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeMapFlowAlias: MetadataConversion = { id: 'flow-node-map-flow-alias', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.map.config.flowName', summary: "map flow-node config key 'flow' → 'flowName' (an undeclared spelling the executor accepted through a bare fallback; it graduates into this layer)", apply(stack, emit) { @@ -1412,11 +1506,23 @@ const flowNodeMapFlowAlias: MetadataConversion = { * including the `AutomationEngine.registerFlow` rehydration seam, so the * executor reads `cfg.flowName` only. * - * A pure key rename with unchanged values. **Live window**; retires at 18. + * A pure key rename with unchanged values. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23487). The authoring funnel no longer rewrites the old + * spelling, so an author meets the `subflow` contract (`SUBFLOW_KEY_GUIDANCE`), whose refusal names the rename and + * this release, at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) replay the full chain, this entry + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. */ const flowNodeSubflowFlowAlias: MetadataConversion = { id: 'flow-node-subflow-flow-alias', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.subflow.config.flowName', summary: "subflow flow-node config key 'flow' → 'flowName' (an undeclared spelling the executor accepted through a bare fallback, found when the schemaless nodes were reconciled with their executors; it graduates into this layer)", apply(stack, emit) { @@ -1519,7 +1625,15 @@ function liftConnectorConfigShape(stack: Dict, emit: Emit): Dict { /** * Connector flow-node loose `config` keys → the declared `connectorConfig` * sibling (protocol 17, #4045). See {@link liftConnectorConfigShape} for the - * precedence rules and the completeness guard. **Live window**; retires at 18. + * precedence rules and the completeness guard. + * + * **Stays on the load path at 18 — a recorded verdict (#23487).** Like `wait`, + * `connector_action` has no `config` contract, so retired, a loose key beside + * a complete `connectorConfig` would be silently ignored: a loose `input` next + * to a declared pair would dispatch the action with no input. Refusing the + * loose keys under `config` would also refuse stored flows whose loose key the + * block shadows (the rehydration seam parses the same schema), so it can retire + * once that shadowed shape has a ruling. */ const flowNodeConnectorConfigLift: MetadataConversion = { id: 'flow-node-connector-config-lift', @@ -1628,7 +1742,17 @@ const flowNodeConnectorConfigLift: MetadataConversion = { * `input` alias almost certainly leaked from `connector_action`, whose * `connectorConfig.input` (singular) is a *different, canonical* surface and is * deliberately not touched here. Both are pure key renames with unchanged - * values. **Live window**; retires at 18. + * values. + * + * **Retired from the load path at 18** — its one-major window (protocol 17) + * has run (#23487). The authoring funnel no longer rewrites the old + * spelling, so an author meets the `script` contract (`SCRIPT_KEY_GUIDANCE`), whose refusal names the rename and + * this release, at every door that judges a flow: `os validate` / + * `os build` / `os lint`. Stored flows keep healing: the automation + * engine's flow rehydration and stored-row rehydration + * (`applyConversionsToStoredItem`) replay the full chain, this entry + * included, and so do the artifact-ingestion door (inside its window) and + * `os migrate meta --from 16`. * * The fixture below carried `actionType: 'invoke_function'` through both sides * until #4343 retired that key — an end state protocol 17 no longer reaches, so @@ -1637,6 +1761,8 @@ const flowNodeConnectorConfigLift: MetadataConversion = { const flowNodeScriptConfigAliases: MetadataConversion = { id: 'flow-node-script-config-aliases', toMajor: 17, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', surface: 'flow.node.script.config', summary: "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (executor `??` fallbacks, graduated into this layer)", apply(stack, emit) { @@ -3520,6 +3646,11 @@ const datasourceConfigDriverKeyAliases: MetadataConversion = { * for a deployment that skipped the migration. There is therefore no loud * rejection for a live-window entry to pre-empt, and every rehydration seam * converging on one spelling is the whole point. + * + * **A recorded verdict at 18 (#23487):** it stays. Retiring it needs a decision + * first: retire `'mongo'` as a value, refused with a prescription naming + * `mongodb`, or record this entry as permanent. While the value is legal there + * is no refusal for a retirement to hand the author to. */ const datasourceDriverMongoToMongodb: MetadataConversion = { id: 'datasource-driver-mongo-to-mongodb', @@ -4056,11 +4187,18 @@ const objectIndexTypePartialRemoved: MetadataConversion = { * default flip 3 → 0 never needed a materialization step while the job one * did: nothing was ever deployed under the old reading. * - * `retiredFromLoadPath` is NOT set: `FlowNodeSchema.config` is an unconstrained - * record, so no schema rejection can reach `config.retry.retryDelayMs` and the - * conversion layer is the only seam that can declare and retire that spelling. - * The `RetryPolicySchema` tombstone still fires for anyone who reaches the - * policy through a parsed job. + * `retiredFromLoadPath` is NOT set, and the base-delay rename is not why: + * every surface that carried `retryDelayMs` already refuses it with the rename + * to `backoffMs` (the `RetryPolicySchema` tombstone, which a `try_catch` node's + * `config.retry` is, and `flow.errorHandling`'s own). + * + * **A recorded verdict at 18 (#23487):** it stays, for the job half alone. It + * writes the pre-17 `maxRetries: 3` / `backoffMultiplier: 2` into a source + * `job.retryPolicy` that omits them; retired, the authoring funnel would stop + * doing that, and such a job would silently drop from three retries to none + * under the merged 0 / 1 defaults — a default flip no gate can see. It can + * retire once that default flip has a ruling for authored sources (or the entry + * is recorded as permanent). */ const retryPolicyConverged: MetadataConversion = { id: 'retry-policy-converged', diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts index 937bbd17f8d..70b697df557 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-script-subflow-config-undeclared-keys-refused.ts @@ -51,10 +51,10 @@ export const entry: SemanticMigration = { + 'own prescriptions. `decision` is schemaless too, but its executor parses no contract, so an ' + 'undeclared key there fails no run and stays unjudged. A retired `script` key keeps its tombstone ' + 'path. ⚠️ A spelling the ADR-0087 D2 conversion `flow-node-script-config-aliases` or ' - + '`flow-node-subflow-flow-alias` still rewrites at load (`functionName`, `input` on a `script`; `flow` ' - + 'on a `subflow`) is converted before the judge at every door that converts first; met by a direct ' - + '`FlowSchema.parse` or `defineFlow()` it is refused like any other undeclared key, as the missing ' - + 'canonical key already was. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was ' + + '`flow-node-subflow-flow-alias` rewrote at load (`functionName`, `input` on a `script`; `flow` on a ' + + '`subflow`) is refused at every authoring door like any other undeclared key, with a prescription ' + + 'naming the rename: both conversions left the load path at 18, and only the rehydration seams for ' + + 'stored flows still convert those spellings before the judge. ⚠️ No D2 conversion: the platform cannot know what an undeclared key was ' + 'meant to be. ⚠️ Where such a node already sits the whole flow is refused: registered from the ' + 'metadata registry or `sys_metadata` at boot it is skipped with a `warn` naming it, its trigger not ' + 'armed, while the flows beside it register; a `defineStack({ flows })` source throws ' diff --git a/packages/spec/src/security/sharing.test.ts b/packages/spec/src/security/sharing.test.ts index 14ab4bdde09..4f497ccbc6c 100644 --- a/packages/spec/src/security/sharing.test.ts +++ b/packages/spec/src/security/sharing.test.ts @@ -63,10 +63,19 @@ describe('SharingLevel', () => { // no code path granted any of those verbs — both gates matched // `edit`/`full` alike, so it was byte-equivalent to `edit` while telling // admins otherwise. Declared-but-unenforced (ADR-0078 / ADR-0049), same - // reason `queue` and `guest` are rejected above. Stacks still authoring it - // are rewritten at load by the `sharing-rule-access-level-full-to-edit` - // conversion, so this rejection is reachable only via a direct parse. - expect(() => SharingLevel.parse('full')).toThrow(); + // reason `queue` and `guest` are rejected above. Its conversion + // (`sharing-rule-access-level-full-to-edit`) left the load path at 18, so + // this rejection is what an author meets at every door — and it is the + // upgrade prescription naming `edit` and the release, never zod's bare + // "expected one of". + const refused = SharingLevel.safeParse('full'); + expect(refused.success).toBe(false); + expect(refused.error!.issues).toHaveLength(1); + expect(refused.error!.issues[0]!.code).toBe('invalid_value'); + const message = refused.error!.issues[0]!.message; + expect(message).toContain("`accessLevel: 'full'` was removed in @objectstack/spec 18"); + expect(message).toContain("Use `accessLevel: 'edit'`"); + expect(message).not.toMatch(/Expected one of/); }); }); diff --git a/packages/spec/src/security/sharing.zod.ts b/packages/spec/src/security/sharing.zod.ts index 986c1de40fa..0571671e98f 100644 --- a/packages/spec/src/security/sharing.zod.ts +++ b/packages/spec/src/security/sharing.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { strictObject } from '../shared/strict-object'; +import { enumWithRetiredValues } from '../shared/retired-key'; /** * Organization-Wide Defaults (OWD) @@ -61,10 +62,22 @@ export const SharingRuleType = z.enum([ * designing a capability mask AND-ed with object CRUD, not re-adding an enum * member (#3865). */ -export const SharingLevel = z.enum([ +export const SharingLevel = enumWithRetiredValues([ 'read', // Read Only 'edit', // Read / Write -]); +], { + // The `sharing-rule-access-level-full-to-edit` conversion (ADR-0087 D2) + // rewrote `full` at load for protocol 17's window; retired from the load + // path at 18 (#23487), it rewrites only stored rows, so an authored `full` + // meets this prescription instead of zod's bare "expected one of". + full: + "Sharing-rule `accessLevel: 'full'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it never granted " + + "more than `edit`: no code path gave transfer, re-share or delete because of it, and the loader rewrote it " + + "to `edit` until this release. Use `accessLevel: 'edit'`. Change the value; the access it grants is " + + 'unchanged. A sharing rule grants read or edit; delete and transfer come from object permissions and ' + + 'ownership. ' + + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', +}); /** * Recipient Type diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 7f0e0c76b96..5a3062c01af 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -79,6 +79,14 @@ import { ALL_CONVERSIONS } from '../conversions/registry'; import { TimeUpdateInterval } from '../data/analytics.zod'; import { IndexSchema, ObjectSchema } from '../data/object.zod'; import { ManifestPermissionsSchema } from '../kernel/manifest.zod'; +import { + DeleteRecordConfigSchema, + GetRecordConfigSchema, + MapConfigSchema, +} from '../automation/builtin-node-config.zod'; +import { NotifyConfigSchema } from '../automation/io-node-config.zod'; +import { ScriptConfigSchema, SubflowConfigSchema } from '../automation/schemaless-node-config.zod'; +import { SharingLevel } from '../security/sharing.zod'; import { PROTOCOL_MAJOR } from '../kernel/protocol-version'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; import { @@ -206,6 +214,9 @@ const ELEMENTS: readonly (readonly [unknown, string, readonly string[]])[] = [ [ElementRepeaterPropsSchema, 'element:repeater', ['object', 'filter', 'sort', 'limit']], ]; +/** A notify config that is valid on its own, so a retired key is its only issue. */ +const NOTIFY_BASE = { recipients: ['u1'], title: 'Hi' }; + /** Retired conversion id → the prescriptions an author meets for its surface. */ const JOIN: Readonly> = { 'element-flat-data-binding-to-data-source': ELEMENTS.flatMap(([schema, surface, keys]) => @@ -254,6 +265,36 @@ const JOIN: Readonly> = { 'action', ), ], + // The family's closing members (#23487): six more entries whose windows had + // run (five `toMajor: 17`, one `toMajor: 11`), each refused by its surface's + // own contract once the loader stopped rewriting it. A node-config row + // carries the contract's required canonical keys beside the retired one, so + // the unrecognized-key refusal is the only issue the parse reports. + 'flow-node-crud-object-alias': [ + refusal("a CRUD node's config.object", () => GetRecordConfigSchema.safeParse({ objectName: 'lead', object: 'lead' })), + ], + 'flow-node-crud-filter-alias': [ + refusal("a CRUD node's config.filters", () => DeleteRecordConfigSchema.safeParse({ objectName: 'lead', filters: { status: 'stale' } })), + ], + 'flow-node-notify-config-aliases': [ + ...(['to', 'subject', 'body', 'url'] as const).map((key) => + refusal(`a notify node's config.${key}`, () => NotifyConfigSchema.safeParse({ ...NOTIFY_BASE, [key]: 'x' })), + ), + refusal("a notify node's nested config.source", () => NotifyConfigSchema.safeParse({ ...NOTIFY_BASE, source: { object: 'lead', id: '1' } })), + ], + 'flow-node-map-flow-alias': [ + refusal("a map node's config.flow", () => MapConfigSchema.safeParse({ collection: [], flowName: 'f', flow: 'f' })), + ], + 'flow-node-subflow-flow-alias': [ + refusal("a subflow node's config.flow", () => SubflowConfigSchema.safeParse({ flowName: 'f', flow: 'f' })), + ], + 'flow-node-script-config-aliases': [ + refusal("a script node's config.functionName", () => ScriptConfigSchema.safeParse({ function: 'f', functionName: 'f' })), + refusal("a script node's config.input", () => ScriptConfigSchema.safeParse({ function: 'f', input: {} })), + ], + 'sharing-rule-access-level-full-to-edit': [ + refusal("sharing-rule accessLevel 'full'", () => SharingLevel.safeParse('full')), + ], // Shipped by a 17.x minor; `toMajor: 18` like the rows above. 'page-header-breadcrumb-removed': [tombstone(PageHeaderProps, 'page:header', 'breadcrumb')], 'object-grid-default-sort-removed': [tombstone(ObjectGridPropsSchema, 'object-grid', 'defaultSort')],