Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/23487-retire-overdue-conversions.md
Original file line number Diff line number Diff line change
@@ -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`.

<!-- adr-0087: not-required (already-registered flow-node-crud-object-alias, flow-node-crud-filter-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) All seven D2 conversions are registered in their migration steps before this change, and they are the whole mechanical migration: this diff marks them retiredFromLoadPath with retiredAfter 17.7.0 and makes the refusals an author now meets name the release, so the authoring funnel stops replaying them while os migrate meta, stored-row and flow rehydration and the artifact door keep replaying them. No key, value or shape is added or removed, so there is no new D2 conversion and no D3 semantic judgment to register: each rewrite is a lossless rename. -->
6 changes: 4 additions & 2 deletions content/docs/references/automation/schemaless-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
Expand All @@ -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());

Expand Down
34 changes: 29 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 8 additions & 4 deletions packages/spec/src/automation/builtin-node-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` → ');
Expand All @@ -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}/);
},
Expand All @@ -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)');
});

Expand Down Expand Up @@ -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);
});

Expand Down
Loading
Loading