Skip to content

Commit c2db30e

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23310-serve-org-record-page
2 parents 891cc17 + 5fd2c76 commit c2db30e

22 files changed

Lines changed: 564 additions & 164 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no (narrowing)
9+
10+
**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`.
11+
12+
Migration:
13+
14+
- 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.
15+
- `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.
16+
- `map` and `subflow` node `config.flow` → `config.flowName`. Rename the key; the value is unchanged.
17+
- `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.)
18+
- Sharing rule `accessLevel: 'full'` → `accessLevel: 'edit'`. `full` never granted more than `edit`.
19+
- 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.
20+
21+
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.)
22+
23+
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.
24+
25+
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.
26+
27+
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`.
28+
29+
<!-- 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. -->

‎content/docs/references/automation/schemaless-node-config.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ form authoring keys nothing reads).
8282

8383
Undeclared aliases are NOT part of these contracts: `subflow`'s historical
8484
`flow` spelling graduated into the ADR-0087 D2 conversion
85-
`flow-node-subflow-flow-alias` (the `map.flow` path), so the executor only
86-
ever sees `flowName`.
85+
`flow-node-subflow-flow-alias` (the `map.flow` path), which left the load
86+
path at 18 (#23487) and rewrites only stored flows, so an authored `flow`
87+
meets `SUBFLOW_KEY_GUIDANCE` and the executor only ever sees
88+
`flowName`.
8789

8890
## Unknown keys — closed as of #4001 批 9, and this class had NO other door
8991

‎packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
* `page-component-visibility-to-visibleWhen` rewrites at load, against
6262
* `visibleWhen`, the canonical spelling that converts nothing. (The pair used
6363
* to differ in the header's `description` against `subtitle`; that conversion,
64-
* `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and
64+
* `page-header-subtitle-alias`, retired from the load path at protocol 18 and
6565
* the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the
6666
* CONVERSION and not on "a page is present" — measured on the pair before it
6767
* was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json`

‎packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,13 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
401401
* [#21982] On the fallback path — no canonicalizer resolved, or it threw —
402402
* the gate judges the body in its ADR-0087 D2-converted spelling, for the
403403
* verdict only. The flow parse refuses an undeclared config key on the
404-
* builtin node types, so without the conversion a D2 spelling every other
405-
* door converts first (`os validate`, `defineStack`, `registerFlow`) would
406-
* be refused here, and only here: a verdict that depended on whether the
407-
* host runs an automation service. What is STORED stays the raw body.
404+
* builtin node types, so without the conversion a D2 spelling the
405+
* canonicalizer converts first (`registerFlow`'s full chain, retired
406+
* entries included) would be refused here, and only here: a verdict that
407+
* depended on whether the host runs an automation service. So the fallback
408+
* replays that same full chain (#23487) — for the seven flow spellings
409+
* retired from the authoring funnel at 18 and for those retired before
410+
* them alike. What is STORED stays the raw body.
408411
*/
409412
describe('[#21982] the fallback gate judges the D2-converted body, and stores the raw one', () => {
410413
const scriptBody = (config: Record<string, unknown>) => ({
@@ -430,6 +433,20 @@ describe('saveMetaItem canonicalizes flow bodies (#4542)', () => {
430433
expect(storedFlow(rows)!.body.nodes[0].config).toEqual({ functionName: 'helpdesk.triage' });
431434
});
432435

436+
it('an older-retired flow shape (`flow.active`) reaches the canonicalizer\'s verdict too — healed for the verdict, stored raw', async () => {
437+
// `flow-inert-keys-removed` (retired from the load path after 16.1.0)
438+
// tombstones `active`. On a host with no automation service the
439+
// fallback used to judge it in the authoring posture and refuse it
440+
// 422; the service-backed path replays the entry and heals it. Since
441+
// #23487 the fallback replays the same chain, so both hosts agree.
442+
const { protocol, rows } = makeProtocol(new Map());
443+
444+
const result = await save(protocol, { ...flowBody({ objectName: 'lead', filter: { status: 'stale' } }), active: false });
445+
446+
expect(result.success).toBe(true);
447+
expect(storedFlow(rows)!.body.active).toBe(false);
448+
});
449+
433450
it('an undeclared key on delete_record is refused 422 INVALID_METADATA at its path — the alias beside it is not', async () => {
434451
const { protocol, rows } = makeProtocol(new Map());
435452

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

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3247,6 +3247,22 @@ function isoFromValidDate(value: unknown): unknown {
32473247
return value;
32483248
}
32493249

3250+
/**
3251+
* ADR-0087 conversions the save door's flow fallback verdict (#21982) does
3252+
* not replay, by id — the same DEFAULT-FLIP refusal the automation engine's
3253+
* canonicalizer makes (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` in
3254+
* `service-automation/src/engine.ts`), because the fallback stands in for that
3255+
* canonicalizer and must reach the same verdict on the same body.
3256+
*
3257+
* - `flow-decision-mode-inclusive-explicit` (#15429) rewrites an omitted
3258+
* decision `mode` into `'inclusive'`, which is sound only where "this body
3259+
* predates the flip" is a fact; a save never knows that, exactly as the
3260+
* engine's canonicalizer does not.
3261+
*/
3262+
const FLOW_FALLBACK_VERDICT_NOT_REPLAYED: readonly string[] = [
3263+
'flow-decision-mode-inclusive-explicit',
3264+
];
3265+
32503266
// Lifecycle columns the engine always owns; the clone path drops them by NAME
32513267
// so the insert re-stamps fresh values instead of copying the source's. Mirrors
32523268
// record-validator's SKIP_FIELDS (system-injected, never author-supplied).
@@ -22405,11 +22421,14 @@ export class ObjectStackProtocolImplementation implements
2240522421
// conversions applied (`applyConversionsToFlow`) — for the VERDICT
2240622422
// only: what is stored stays the raw request body, exactly as before.
2240722423
// The flow parse judges an undeclared config key on the builtin node
22408-
// types, so a D2 spelling the load path still rewrites (`filters`,
22424+
// types, so a D2 spelling the canonicalizer rewrites (`filters`,
2240922425
// script `functionName`, subflow `flow`, …) would otherwise be refused
22410-
// here and accepted at every converting door (`os validate`,
22411-
// `defineStack`, `registerFlow`): a verdict that depended on whether
22412-
// this host runs an automation service. No `reservedNodeTypes` (no
22426+
// here and accepted by `registerFlow`, which the canonicalizer is: a
22427+
// verdict that depended on whether this host runs an automation
22428+
// service. So the fallback replays the chain the canonicalizer replays
22429+
// — the FULL chain, retired entries included (#23487 retired those
22430+
// spellings from the authoring funnel, not from this seam), minus the
22431+
// same default flip ({@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}). No `reservedNodeTypes` (no
2241322432
// engine here): a node-type rename's conflict guard keeps its one home
2241422433
// in the engine's canonicalizer, and this converted body is never
2241522434
// persisted. A key no conversion rewrites is judged exactly as written.
@@ -22484,7 +22503,12 @@ export class ObjectStackProtocolImplementation implements
2248422503
canonicalized = true;
2248522504
}
2248622505
}
22487-
if (!canonicalized) flowGateVerdictBody = applyConversionsToFlow(request.item);
22506+
if (!canonicalized) {
22507+
flowGateVerdictBody = applyConversionsToFlow(request.item, {
22508+
includeRetired: true,
22509+
excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED,
22510+
});
22511+
}
2248822512
}
2248922513

2249022514
// Spec-conformance check: if a Zod schema is registered for this

‎packages/services/service-automation/src/builtin/crud-config-aliases.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,19 @@ describe('CRUD config-key aliases: object→objectName + filters→filter (load-
9898
expect(calls[0].opts.where).toEqual({ id: 'L1' }); // filter preserved, not dropped
9999
});
100100

101-
it('the same conversions run on the build/validate seam too (normalizeStackInput)', async () => {
101+
it('the build/validate seam no longer converts them (retired at 18), and the rehydration seam still does', async () => {
102102
const engine = new AutomationEngine(silentLogger());
103103
const { data, calls } = fakeData();
104104
registerCrudNodes(engine, ctxWith(data, silentLogger()));
105105

106+
// Both conversions left the load path at 18 (#23487): the authoring
107+
// funnel hands the aliases on as written, for the node contract's
108+
// refusal to name the rename…
106109
const raw = getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' });
107-
const converted = (normalizeStackInput({ flows: [raw] }).flows as any[])[0];
108-
engine.registerFlow('gr', converted);
110+
const authored = (normalizeStackInput({ flows: [raw] }).flows as any[])[0];
111+
expect(authored.nodes[1].config).toEqual({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' });
112+
// …while a flow that reaches the engine as data at rest still heals.
113+
engine.registerFlow('gr', authored);
109114
await engine.execute('gr', { userId: 'u1' });
110115

111116
expect(calls[0].obj).toBe('crm_lead');

‎packages/spec/scripts/liveness/check-liveness.mts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -793,16 +793,33 @@ const producerEntries: ProducerEntry[] = [];
793793

794794
const proofFs = { existsSync, readFileSync };
795795

796+
/**
797+
* One read per cited file per run (#23180). The walk cites the same consumers
798+
* from hundreds of rows — `packages/rest/src/rest-server.ts` alone 142 times —
799+
* and reading each citation afresh made one run read 455.8 MB to check 2,367
800+
* citations over 828 files: nearly half the run's wall, and the cost the
801+
* self-test pays on every one of its gate spawns. Nothing in the repo moves
802+
* under one run, so a memo keyed by the repo-relative path changes no verdict;
803+
* a missing file is memoized as missing for the same reason.
804+
*/
805+
const citedText = new Map<string, string | null>();
806+
function readCited(p: string): string | null {
807+
if (citedText.has(p)) return citedText.get(p) ?? null;
808+
const f = join(repoRoot, p);
809+
const text = existsSync(f) ? readFileSync(f, 'utf8') : null;
810+
citedText.set(p, text);
811+
return text;
812+
}
813+
796814
/**
797815
* How many lines the cited file has, or `null` when it cannot be read. `null`
798816
* is not a pass: it means the EXISTENCE check owns this path's verdict already
799817
* (a rotted `evidence` pointer would otherwise be reported twice, once per
800818
* check, which teaches a reader to discount both lists).
801819
*/
802820
function lineCountOf(p: string): number | null {
803-
const f = join(repoRoot, p);
804-
if (!existsSync(f)) return null;
805-
return countLines(readFileSync(f, 'utf8'));
821+
const text = readCited(p);
822+
return text === null ? null : countLines(text);
806823
}
807824

808825
/**
@@ -811,9 +828,7 @@ function lineCountOf(p: string): number | null {
811828
* from the existence check and must not be reported a second time here.
812829
*/
813830
function contentOf(p: string): string | null {
814-
const f = join(repoRoot, p);
815-
if (!existsSync(f)) return null;
816-
return readFileSync(f, 'utf8');
831+
return readCited(p);
817832
}
818833

819834
/** Unanchored (entry, cited path) pairs the walk observes, reconciled after it. */

‎packages/spec/src/automation/builtin-node-config.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
7575
const message = unknownKeyMessage(schema, { ...base, object: 'lead' })!;
7676
expect(message).toContain(`this ${nodeType} node config`);
7777
expect(message).toContain('`objectName`');
78-
expect(message).toContain('flow-node-crud-object-alias');
78+
// The upgrade prescription since the conversion left the load path at 18.
79+
expect(message).toContain('`object` was removed in @objectstack/spec 18');
80+
expect(message).toContain('Use `objectName`. Rename the key');
7981
// The distance claim in the schema's comment, pinned: without the
8082
// curated entry this key would get NO suggestion at all.
8183
expect(message).not.toContain('`object` → ');
@@ -90,7 +92,8 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
9092
'%s: prescribes `filter` for the retired `filters` spelling, and names the match-everything-write hazard',
9193
(_nodeType, schema, base) => {
9294
const message = unknownKeyMessage(schema, { ...base, filters: { status: 'stale' } })!;
93-
expect(message).toContain('flow-node-crud-filter-alias');
95+
expect(message).toContain('`filters` was removed in @objectstack/spec 18');
96+
expect(message).toContain('Use `filter`. Rename the key');
9497
expect(message).toContain('match-everything write');
9598
expect(message, 'the prescription names the hazard, never a tracker id').not.toMatch(/#\d{3,5}/);
9699
},
@@ -101,7 +104,7 @@ describe('CRUD config contracts — an unknown key is refused, not stripped', ()
101104
// not declare. `create_record` is outside `flow-node-crud-filter-alias`'s
102105
// node-type set precisely because it has no filter.
103106
const message = unknownKeyMessage(CreateRecordConfigSchema, { objectName: 'task', filters: {} })!;
104-
expect(message).not.toContain('flow-node-crud-filter-alias');
107+
expect(message).not.toContain('Use `filter`');
105108
expect(message).not.toContain('`filter` (singular)');
106109
});
107110

@@ -412,7 +415,8 @@ describe('MapConfigSchema — an unknown key is refused, not stripped', () => {
412415
it('prescribes `flowName` for the undeclared `flow` fallback', () => {
413416
const message = unknownKeyMessage(MapConfigSchema, { collection: { dialect: 'cel', source: 'tasks' }, flowName: 'per_row', flow: 'ignored' })!;
414417
expect(message).toContain('this map node config');
415-
expect(message).toContain('flow-node-map-flow-alias');
418+
expect(message).toContain('`flow` was removed in @objectstack/spec 18');
419+
expect(message).toContain('Use `flowName`');
416420
expect(message).toMatch(/delete/i);
417421
});
418422

0 commit comments

Comments
 (0)