From 07ac66b962793243927bd91deecd8b8c2b2da352 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:09:33 +0000 Subject: [PATCH 1/5] wip(spec): FlowSchema refuses a write node aimed at a stored-metadata table Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../automation/flow-node-config-refusals.ts | 90 ++++++- .../automation/flow-node-expression-paths.ts | 13 +- .../flow-slot-refusal-codes.test.ts | 31 +++ ...-write-node-stored-metadata-target.test.ts | 227 ++++++++++++++++++ packages/spec/src/data/hook.zod.ts | 12 +- .../src/kernel/metadata-type-redaction.ts | 13 +- .../kernel/stored-metadata-body-objects.ts | 20 ++ ...ite-node-stored-metadata-target-refused.ts | 49 ++++ packages/spec/src/migrations/registry.ts | 61 +++++ 9 files changed, 496 insertions(+), 20 deletions(-) create mode 100644 packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts diff --git a/packages/spec/src/automation/flow-node-config-refusals.ts b/packages/spec/src/automation/flow-node-config-refusals.ts index d82bd029f7d..82a173479eb 100644 --- a/packages/spec/src/automation/flow-node-config-refusals.ts +++ b/packages/spec/src/automation/flow-node-config-refusals.ts @@ -3,12 +3,13 @@ /** * @module automation/flow-node-config-refusals * - * **What a node's executor needs its `config` to carry** (#20316) — the one - * judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses + * **What a node's executor needs its `config` to carry** (#20316), and + * (#21654) **the one target a write node's executor refuses to write** — the + * one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses * first) and `objectstack validate` share, beside the expression ledger's * `predicateSlotRefusal` and closing the same gap: a node's `config` is an - * open `z.record`, so what its executor requires was checked by nobody until - * the run. + * open `z.record`, so what its executor requires, or refuses, was checked by + * nobody until the run. * * Its refusal codes join the closed flow slot table * (`FLOW_SLOT_REFUSAL_CODES`, `flow-node-expression-paths.ts`); the @@ -18,6 +19,10 @@ */ import { NON_BLANK_STRING } from '../shared/refinement-projection'; +// [#21654] The stored-metadata family's ONE membership predicate and the ONE +// prescription a refusal of its reach ends on — both from the import-free leaf, +// so this module's import graph gains nothing. ⛔ Never restate either here. +import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects'; import { FLOW_REGION_SLOTS_BY_TYPE } from './region-slots'; import { FLOW_NODE_EXPRESSION_PATHS } from './flow-node-expression-paths'; import type { FlowNodeConfigRefusal, FlowSlotRefusalParams, NodeConfigValueKind } from './flow-node-expression-paths'; @@ -179,6 +184,53 @@ function insideValueSlot(nodeType: string, path: ReadonlyArray): bo }); } +/** + * [#21654] The write nodes, each with the verb its refusal names — the same + * three, in the same words, as the run-time refusal in `service-automation` + * (`storedMetadataWriteRefusal`, `builtin/crud-nodes.ts`). ⛔ Never `get_record`: + * a read is not a write, and its family reach is refused at the run. + */ +const STORED_METADATA_WRITE_VERB = { + create_record: 'create a record in', + update_record: 'update', + delete_record: 'delete from', +} as const; + +/** A node type in {@link STORED_METADATA_WRITE_VERB}. */ +function isStoredMetadataWriteNodeType(nodeType: string): nodeType is keyof typeof STORED_METADATA_WRITE_VERB { + return Object.prototype.hasOwnProperty.call(STORED_METADATA_WRITE_VERB, nodeType); +} + +/** + * The write-target arm of {@link flowNodeConfigRefusals} (#21654): a + * `create_record`, `update_record` or `delete_record` node whose `objectName` + * is a STATIC string naming a stored-metadata table, judged by the family's + * own predicate, by exact name — the set the run-time refusal refuses, read + * where the flow is built instead of where it first runs. `undefined` for + * anything else. + * + * A static name only. A value the parse cannot read as a name is the run's to + * judge: an expression envelope is not a string, and a `{token}` template is + * never a family name by exact match — the run-time half judges the name the + * node hands the data engine. + */ +function storedMetadataWriteTargetRefusal(nodeType: string, config: unknown): FlowNodeConfigRefusal | undefined { + if (!isStoredMetadataWriteNodeType(nodeType) || !isRecord(config)) return undefined; + const objectName = config.objectName; + if (typeof objectName !== 'string' || !isStoredMetadataBodyObject(objectName)) return undefined; + return { + code: 'write-node-stored-metadata-target', + params: { nodeType, objectName }, + message: + `This \`${nodeType}\` node's \`objectName\` is '${objectName}', so it would ` + + `${STORED_METADATA_WRITE_VERB[nodeType]} a table that holds stored metadata, and a flow may not write it ` + + 'directly: every run that reaches the node refuses it before anything is written, and re-running ' + + `changes nothing. ${STORED_METADATA_BODY_PRESCRIPTION}`, + source: '', + path: 'objectName', + }; +} + /** The refusal for a key a node's executor contract requires. */ function nodeConfigKeyMissingMessage(nodeType: string, key: string): string { return ( @@ -190,11 +242,12 @@ function nodeConfigKeyMissingMessage(nodeType: string, key: string): string { } /** - * Every reason a node's `config` is refused on SHAPE or PRESENCE — the ONE - * judge `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses - * first) and `objectstack validate` share (#20316). + * Every reason a node's `config` is refused on SHAPE or PRESENCE, and (#21654) + * a write node's static TARGET in the stored-metadata family — the ONE judge + * `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and + * `objectstack validate` share (#20316). * - * Two arms. + * Three arms. * * ## The executor contract — a key it requires, absent * @@ -240,6 +293,25 @@ function nodeConfigKeyMissingMessage(nodeType: string, key: string): string { * The branch's `expression` is not judged here: it is a ledger `predicate` * slot, refused absent / blank / non-text by `predicateSlotRefusal`. * + * ## A write node aimed at the stored-metadata family + * + * The family (`sys_metadata`, `sys_metadata_history`) has one writer for + * app-authored work: the metadata protocol, where a change is validated and + * its provenance recorded (#21520, ruling A, applied to flows by #21624). The + * `create_record`, `update_record` and `delete_record` executors refuse a + * family target at run time, before any write; a flow naming one statically + * used to save, register and validate clean, and failed at its first run. + * + * - A write node whose `objectName` is a string naming a family table → + * `write-node-stored-metadata-target`, anchored at `objectName`, whose + * message names the node type and the table and ends on the leaf's + * `STORED_METADATA_BODY_PRESCRIPTION`. + * + * Judged whatever else the config carries: the run refuses such a node either + * way — by its contract parse when that fails, by the target right after it + * when it holds. A dynamic target is the run's: see + * {@link storedMetadataWriteTargetRefusal}. + * * Every refusal carries `source: ''`: none of these values is CEL text. */ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowNodeConfigRefusal[] { @@ -248,6 +320,8 @@ export function flowNodeConfigRefusals(nodeType: string, config: unknown): FlowN decisionShapeRefusals(config, out); return out; } + const writeTarget = storedMetadataWriteTargetRefusal(nodeType, config); + if (writeTarget) out.push(writeTarget); const contract = getBuiltinNodeConfigContracts().get(nodeType); if (!contract) return out; const authored = config ?? {}; diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 7d353fd1fc3..d99d4e740ef 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -546,6 +546,15 @@ export interface FlowSlotRefusalParams { * the node's `config`. */ 'node-config-key-required-by-rule': { readonly nodeType: string; readonly key: string }; + /** + * (#21654) A `create_record` / `update_record` / `delete_record` node whose + * `config.objectName` is a static string naming a stored-metadata table + * (`isStoredMetadataBodyObject`) — a table a flow may not write directly. + */ + 'write-node-stored-metadata-target': { + readonly nodeType: 'create_record' | 'update_record' | 'delete_record'; + readonly objectName: string; + }; } /** Every refusal code the three flow slot refusal producers emit. */ @@ -563,7 +572,8 @@ export type FlowNodeConfigRefusalCode = | 'decision-branch-not-object' | 'decision-branch-label-missing' | 'node-config-key-missing' - | 'node-config-key-required-by-rule'; + | 'node-config-key-required-by-rule' + | 'write-node-stored-metadata-target'; /** One refusal's `code` and `params`, correlated: narrowing on `code` narrows `params`. */ type FlowSlotRefusalOf = { message: string; source: string } & { @@ -607,6 +617,7 @@ const FLOW_SLOT_REFUSAL_CODE_TABLE = { 'decision-branch-label-missing': true, 'node-config-key-missing': true, 'node-config-key-required-by-rule': true, + 'write-node-stored-metadata-target': true, } as const satisfies Record; /** diff --git a/packages/spec/src/automation/flow-slot-refusal-codes.test.ts b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts index c74494b6df4..b71e9be826a 100644 --- a/packages/spec/src/automation/flow-slot-refusal-codes.test.ts +++ b/packages/spec/src/automation/flow-slot-refusal-codes.test.ts @@ -39,6 +39,7 @@ import { import * as automation from './index.js'; import { flowNodeConfigRefusals } from './flow-node-config-refusals.js'; import { NotifyConfigSchema } from './io-node-config.zod.js'; +import { STORED_METADATA_BODY_PRESCRIPTION } from '../kernel/stored-metadata-body-objects.js'; /** What one refusal says, whichever producer said it. */ interface Said { @@ -91,6 +92,12 @@ const KEY_MISSING = (nodeType: string, key: string): string => + 'flow used to register, and then every run that reached this node failed there; the config is metadata, and ' + `re-running changes nothing. Write \`${key}\` on the node's \`config\`.`; +/** [#21654] A write node aimed at a stored-metadata table — the verb is the run-time refusal's. */ +const FAMILY_WRITE = (nodeType: string, verb: string, objectName: string): string => + `This \`${nodeType}\` node's \`objectName\` is '${objectName}', so it would ${verb} a table that holds stored ` + + 'metadata, and a flow may not write it directly: every run that reaches the node refuses it before anything is ' + + `written, and re-running changes nothing. ${STORED_METADATA_BODY_PRESCRIPTION}`; + /** The notify contract's own words for a node with no content source — read, never re-spelled. */ const NOTIFY_TITLE_RULE = (() => { const own = NotifyConfigSchema.safeParse({ recipients: ['u1'] }); @@ -293,6 +300,26 @@ const PINS: { readonly [C in FlowSlotRefusalCode]: readonly [Pin, ...Pin[] source: '', }, ], + 'write-node-stored-metadata-target': [ + { + produce: nodeConfig('create_record', { objectName: 'sys_metadata', fields: { name: 'x' } }), + params: { nodeType: 'create_record', objectName: 'sys_metadata' }, + message: FAMILY_WRITE('create_record', 'create a record in', 'sys_metadata'), + source: '', + }, + { + produce: nodeConfig('update_record', { objectName: 'sys_metadata_history', filter: { id: '1' } }), + params: { nodeType: 'update_record', objectName: 'sys_metadata_history' }, + message: FAMILY_WRITE('update_record', 'update', 'sys_metadata_history'), + source: '', + }, + { + produce: nodeConfig('delete_record', { objectName: 'sys_metadata', filter: { id: '1' } }), + params: { nodeType: 'delete_record', objectName: 'sys_metadata' }, + message: FAMILY_WRITE('delete_record', 'delete from', 'sys_metadata'), + source: '', + }, + ], }; describe('flow slot refusal codes — one pin per code (code, params, unchanged message)', () => { @@ -362,6 +389,7 @@ const NODE_CONFIG_CODES: ReadonlySet = new Set = [ ['loop', { body: { nodes: [{ id: 'b', type: 'assignment', label: 'B' }], edges: [] } }], ['screen', { fields: [{ label: 'x', options: [{}] }] }], ['assignment', {}], + ...SWEEP.map((value) => ['create_record', { objectName: value }] as const), + ['update_record', { objectName: 'sys_metadata_history' }], + ['delete_record', { objectName: 'sys_metadata' }], ]; describe('flow slot refusal codes — the closed set', () => { diff --git a/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts b/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts new file mode 100644 index 00000000000..9fab850a21e --- /dev/null +++ b/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21654] `FlowSchema` refuses a `create_record`, `update_record` or + * `delete_record` node whose STATIC `config.objectName` names a table of stored + * metadata — the save-time half of #21624, whose run-time half refuses the same + * node in `service-automation` before any write. + * + * The refusal is an arm of `flowNodeConfigRefusals`, the one judge + * `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and + * `objectstack validate` share, so every door that parses a flow meets it: + * `defineStack`, the registered `flow` type schema the metadata save door + * validates against, and an artifact's parse — pinned here — and + * `registerFlow` / `validateStackExpressions`, which call the same judge. + * + * The refused set is the run's, by exact name: a write node whose `objectName` + * is a string the family predicate (`isStoredMetadataBodyObject`) answers for. + * Not wider — a read node, an ordinary object, a near-miss name and a dynamic + * target (a `{token}` template, an expression envelope) all parse — and not + * narrower — every family table, every write node, at any depth. + */ + +import { describe, expect, it } from 'vitest'; + +import { + STORED_METADATA_BODY_OBJECTS, + STORED_METADATA_BODY_PRESCRIPTION as KERNEL_PRESCRIPTION, + isStoredMetadataBodyObject, +} from '../kernel/metadata-type-redaction'; +import * as leaf from '../kernel/stored-metadata-body-objects'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ArtifactStagePackageBodySchema, defineStack } from '../stack.zod'; +import { HookSchema } from '../data/hook.zod'; +import { flowNodeConfigRefusals } from './flow-node-config-refusals'; +import { FlowSchema } from './flow.zod'; + +const ENTRY_ID = 'flow-write-node-stored-metadata-target-refused'; +const FAMILY = [...STORED_METADATA_BODY_OBJECTS]; +const WRITE_NODES = ['create_record', 'update_record', 'delete_record'] as const; + +type Config = Record; +type WriteNode = (typeof WRITE_NODES)[number]; + +/** A whole, valid config for `nodeType` aimed at `objectName` — the accept control's shape. */ +function configFor(nodeType: WriteNode | 'get_record', objectName: unknown): Config { + if (nodeType === 'create_record') return { objectName, fields: { name: 'pin' } }; + if (nodeType === 'update_record') return { objectName, filter: { id: '{record.id}' }, fields: { state: 'archived' } }; + if (nodeType === 'delete_record') return { objectName, filter: { id: '{record.id}' } }; + return { objectName, filter: { id: '{record.id}' }, outputVariable: 'row' }; +} + +/** start → the probe node → end. */ +function flowWith(node: Record) { + return { + name: 'family_write_probe', + label: 'Family write probe', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'probe', label: 'Probe', ...node }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'probe' }, + { id: 'e2', source: 'probe', target: 'end' }, + ], + }; +} + +interface IssueSig { code: string; path: string; message: string } + +function issuesOf(flow: unknown): IssueSig[] { + const r = FlowSchema.safeParse(flow); + return r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +} + +describe('FlowSchema refuses a write node whose static objectName is a stored-metadata table', () => { + for (const nodeType of WRITE_NODES) { + it.each(FAMILY)(`${nodeType} on '%s' is refused at nodes.1.config.objectName, ending on the prescription`, (table) => { + const issues = issuesOf(flowWith({ type: nodeType, config: configFor(nodeType, table) })); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'custom', path: 'nodes.1.config.objectName' }]); + const [{ message }] = issues; + // The judge's own words, never re-spelled — and they name the node, the table and the metadata protocol. + expect(message).toBe(flowNodeConfigRefusals(nodeType, configFor(nodeType, table))[0]!.message); + expect(message).toContain(`\`${nodeType}\``); + expect(message).toContain(`'${table}'`); + expect(message.endsWith(leaf.STORED_METADATA_BODY_PRESCRIPTION)).toBe(true); + }); + } + + it('the judge answers with its code, params and path for each write node', () => { + for (const nodeType of WRITE_NODES) { + for (const objectName of FAMILY) { + const refusals = flowNodeConfigRefusals(nodeType, configFor(nodeType, objectName)); + expect(refusals.map(({ code, params, path, source }) => ({ code, params, path, source }))).toEqual([ + { code: 'write-node-stored-metadata-target', params: { nodeType, objectName }, path: 'objectName', source: '' }, + ]); + } + } + }); + + it('a write node inside an ADR-0031 region body is refused at the path the author wrote', () => { + const issues = issuesOf(flowWith({ + type: 'try_catch', + config: { + try: { nodes: [{ id: 'inner_write', type: 'delete_record', label: 'Inner', config: configFor('delete_record', 'sys_metadata') }], edges: [] }, + catch: { nodes: [{ id: 'inner_catch', type: 'assignment', label: 'Catch' }], edges: [] }, + }, + })); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([ + { code: 'custom', path: 'nodes.1.config.try.nodes.0.config.objectName' }, + ]); + }); + + it('the target is judged whatever else the config carries — a bulk update with no filter is refused too', () => { + expect(issuesOf(flowWith({ type: 'update_record', config: { objectName: 'sys_metadata', multi: true } })).map((i) => i.path)) + .toEqual(['nodes.1.config.objectName']); + }); +}); + +describe('what stays accepted at save (lit controls)', () => { + it.each(WRITE_NODES)('CONTROL: %s on an ordinary object parses', (nodeType) => { + expect(issuesOf(flowWith({ type: nodeType, config: configFor(nodeType, 'crm_account') }))).toEqual([]); + }); + + it.each(WRITE_NODES)('CONTROL: %s with a dynamic objectName — a {token} template or an expression envelope — is not judged at save', (nodeType) => { + for (const objectName of ['{record.target}', '{target}', { dialect: 'cel', source: "'sys_metadata'" }]) { + expect(issuesOf(flowWith({ type: nodeType, config: configFor(nodeType, objectName) })), JSON.stringify(objectName)).toEqual([]); + expect(flowNodeConfigRefusals(nodeType, configFor(nodeType, objectName))).toEqual([]); + } + }); + + it.each(FAMILY)('CONTROL: a get_record node on \'%s\' is not judged by this arm — a read is not a write', (table) => { + expect(issuesOf(flowWith({ type: 'get_record', config: configFor('get_record', table) }))).toEqual([]); + }); + + it('the refused set is the family predicate\'s, by exact name — never a second list', () => { + for (const objectName of [...FAMILY, 'SYS_METADATA', 'sys_metadata_draft', ' sys_metadata', 'sys_meta', 'metadata', 'crm_account']) { + for (const nodeType of WRITE_NODES) { + const refused = issuesOf(flowWith({ type: nodeType, config: configFor(nodeType, objectName) })).length > 0; + expect(refused, `${nodeType} on '${objectName}'`).toBe(isStoredMetadataBodyObject(objectName)); + } + } + }); +}); + +describe('every door that parses a flow refuses it', () => { + const stackWith = (flows: unknown[]) => ({ + manifest: { id: 'com.example.flows', name: 'flows', version: '1.0.0', type: 'app', namespace: 'fws' }, + objects: [{ name: 'fws_note', label: 'Note', fields: { title: { type: 'text', label: 'Title' } } }], + flows, + }); + const named = (name: string, objectName: string) => ({ ...flowWith({ type: 'create_record', config: configFor('create_record', objectName) }), name }); + + it('defineStack wraps the refusal in its ADR-0112 envelope, at flows.N.nodes.1.config.objectName', () => { + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack(stackWith([named('fws_ok', 'fws_note'), named('fws_family', 'sys_metadata_history')]) as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse the family-target write flow').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal!.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'flows.1.nodes.1.config.objectName', code: 'custom' }, + ]); + }); + + it('CONTROL: defineStack accepts the ordinary write flow alone', () => { + expect(() => defineStack(stackWith([named('fws_ok', 'fws_note')]) as never)).not.toThrow(); + }); + + it('the registered `flow` type schema — what the metadata save door validates against — refuses it too', () => { + const schema = getMetadataTypeSchema('flow') as unknown as typeof FlowSchema; + expect(schema).toBeDefined(); + const r = schema.safeParse(named('fws_family', 'sys_metadata')); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))).toEqual(['nodes.1.config.objectName']); + // CONTROL: the same schema accepts the ordinary target. + expect(schema.safeParse(named('fws_ok', 'fws_note')).success).toBe(true); + }); + + it('an artifact\'s parse refuses it', () => { + const body = { id: 'com.example.flows', name: 'flows', version: '1.0.0', type: 'app' }; + const refused = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [named('fws_family', 'sys_metadata')] }); + expect(refused.success).toBe(false); + expect(refused.success ? [] : refused.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.objectName']); + // CONTROL: the ordinary target parses at the same door. + const accepted = ArtifactStagePackageBodySchema.safeParse({ ...body, flows: [named('fws_ok', 'crm_account')] }); + expect(accepted.success, JSON.stringify(accepted.error?.issues ?? [])).toBe(true); + }); +}); + +describe('one prescription sentence', () => { + it('the kernel entry re-exports the leaf\'s prescription, and it names the metadata protocol', () => { + expect(KERNEL_PRESCRIPTION).toBe(leaf.STORED_METADATA_BODY_PRESCRIPTION); + expect(KERNEL_PRESCRIPTION).toContain('the metadata protocol'); + }); + + it('the hook refusal and the flow refusal end on the same sentence', () => { + const hook = HookSchema.safeParse({ name: 'stamp', object: 'sys_metadata', events: ['beforeInsert'], body: { language: 'js', source: '1' } }); + expect(hook.success).toBe(false); + const hookMessage = hook.success ? '' : hook.error.issues[0]!.message; + const flowMessage = flowNodeConfigRefusals('create_record', configFor('create_record', 'sys_metadata'))[0]!.message; + expect(hookMessage.endsWith(leaf.STORED_METADATA_BODY_PRESCRIPTION)).toBe(true); + expect(flowMessage.endsWith(leaf.STORED_METADATA_BODY_PRESCRIPTION)).toBe(true); + }); +}); + +describe('the ADR-0087 ledger', () => { + it('registers one D3 entry at protocol 18, with no D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((e) => e.id === ENTRY_ID); + expect(entries, 'the narrowing needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.conversionIds ?? []).toEqual([]); + expect(entry!.replacement).toContain('the metadata protocol'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('registers no tombstone: objectName stays in every write node contract', () => { + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => /(Create|Update|Delete)RecordConfig:objectName$/.test(k))).toEqual([]); + // CONTROL: the flattened table is the real one — it carries a known step-18 tombstone. + expect(all).toContain('api/RestApiEndpoint:timeout'); + }); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index cf6acec5eaf..b20a239cc9c 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -12,7 +12,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; // its table list here. Imported from the import-free leaf, not from // `metadata-type-redaction.ts`, whose closure (the credential derivation and the // conversion chain) has no business in this schema's import graph. -import { isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects'; +import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects'; import { HookBodySchema } from './hook-body.zod'; // Type-only, and it must stay that way: `contracts/` already imports `data/` // (`contracts/data-engine.ts`), so a VALUE import here would close a runtime @@ -103,14 +103,10 @@ const hookTargetError = * wildcard `'*'`, which names no family table: it binds, and the runtime never * runs its body for a family table's event. * - * The prescription repeats the runtime's sentence word for word: the runtime - * keeps it in a module-private constant `packages/spec` cannot import, and no - * shared constant exists. + * The prescription repeats the runtime's sentence word for word: it is the + * leaf's exported `STORED_METADATA_BODY_PRESCRIPTION`, imported above, which + * the flow write-node refusal ends on as well. */ -const STORED_METADATA_BODY_PRESCRIPTION = - 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' - + 'where it is validated and its provenance is recorded. Elevation (`runAs`, a system context) does not ' - + 'change this.'; /** * The object-level check that refuses a hook `body` bound to a stored-metadata diff --git a/packages/spec/src/kernel/metadata-type-redaction.ts b/packages/spec/src/kernel/metadata-type-redaction.ts index e3b21fa6c7c..a6765170c50 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.ts @@ -49,7 +49,12 @@ import { redactDatasourceConfig } from '../data/datasource-credential-redaction'; import { PLURAL_TO_SINGULAR } from '../shared/metadata-collection.zod'; // [#21565] The family set and its predicate: declared in a leaf, re-exported below. -import { STORED_METADATA_BODY_OBJECTS, isStoredMetadataBodyObject } from './stored-metadata-body-objects'; +// [#21654] So is the one prescription a refusal of the family's reach ends on. +import { + STORED_METADATA_BODY_OBJECTS, + STORED_METADATA_BODY_PRESCRIPTION, + isStoredMetadataBodyObject, +} from './stored-metadata-body-objects'; /** What a {@link MetadataTypeRedactor} returns: the servable item, and what was withheld. */ export interface MetadataRedactionResult { @@ -162,9 +167,11 @@ export function listMetadataTypeRedactorTypes(): string[] { * importer of this module and of `@objectstack/spec/kernel` receives the very * same objects. [#21565] They moved so that `data/hook.zod.ts` can judge a hook * target by the predicate without importing this module's closure; the leaf's - * header says why. ⛔ Never restate the list here. + * header says why. ⛔ Never restate the list here. [#21654] The prescription a + * refusal of the family's reach ends on is re-exported beside them, for the + * same reason: one sentence, imported wherever it is said. */ -export { STORED_METADATA_BODY_OBJECTS, isStoredMetadataBodyObject }; +export { STORED_METADATA_BODY_OBJECTS, STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject }; /** The column holding the serialized body, on every {@link STORED_METADATA_BODY_OBJECTS} member. */ export const STORED_METADATA_BODY_COLUMN = 'metadata'; diff --git a/packages/spec/src/kernel/stored-metadata-body-objects.ts b/packages/spec/src/kernel/stored-metadata-body-objects.ts index cab087362f5..969b4da7b1c 100644 --- a/packages/spec/src/kernel/stored-metadata-body-objects.ts +++ b/packages/spec/src/kernel/stored-metadata-body-objects.ts @@ -55,3 +55,23 @@ export const STORED_METADATA_BODY_OBJECTS: ReadonlySet = new Set([ export function isStoredMetadataBodyObject(object: string): boolean { return STORED_METADATA_BODY_OBJECTS.has(object); } + +/** + * [#21654] The ONE prescription an author is shown when app-authored work + * reaches for a {@link STORED_METADATA_BODY_OBJECTS} table: where a change to + * metadata goes instead. The family has one writer for app-authored work, the + * metadata protocol, where a change is validated and its provenance recorded + * (#21520, ruling A), so every refusal of that reach ends on this sentence. + * + * Read by `HookSchema`'s refusal of a hook body bound to a family table + * (`data/hook.zod.ts`) and by `FlowSchema`'s refusal of a write node aimed at + * one (`automation/flow-node-config-refusals.ts`). Declared here, in the + * import-free leaf, so both schemas reach it without the redaction module's + * closure, and published from `@objectstack/spec/kernel` beside the set, so a + * runtime refusal can say the same sentence by importing it rather than by + * keeping a copy. ⛔ Never restate it in a refusal: import it. + */ +export const STORED_METADATA_BODY_PRESCRIPTION = + 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' + + 'where it is validated and its provenance is recorded. Elevation (`runAs`, a system context) does not ' + + 'change this.'; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts new file mode 100644 index 00000000000..0ed4c024d4b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.flow-write-node-stored-metadata-target-refused.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a +// stored-metadata table: the save-time half of #21624, which applies #21520's +// ruling A (record 5965059068) to flows, whose run-time half refuses the same +// node before any write. It narrows a flow's accept set; no key is removed, so +// there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is no D2 +// conversion either: a refused node carries no intent the chain could rewrite +// into one the runtime runs. +export const entry: SemanticMigration = { + id: 'flow-write-node-stored-metadata-target-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'a create_record, update_record or delete_record flow node whose config.objectName is the string ' + + 'sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body', + replacement: + 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' + + 'where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at ' + + 'the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.', + reason: + '`FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` ' + + 'names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ' + + 'ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their ' + + 'only writer, where a change is validated and its provenance recorded, and a flow is app-authored ' + + 'automation. The runtime enforces that at the node, refusing the write before it resolves a filter, ' + + 'computes a field or calls the data engine, under every run identity; but every authoring door still ' + + 'accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses ' + + 'it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' + + '`objectstack validate` share (`flowNodeConfigRefusals`), with the runtime\'s prescription: ' + + '`objectstack validate`, `defineStack`, compile, an artifact\'s parse, `registerFlow` and the metadata ' + + 'save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the ' + + 'runtime\'s: one of those three write nodes, whose `objectName` is a string naming a stored-metadata ' + + 'table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic ' + + 'target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the ' + + 'run judges the name it hands the data engine. No authored flow writing either table was measured in this ' + + 'repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the ' + + 'node or deleting it each changes what the author wrote, and the runtime already never ran it. Where ' + + 'such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is ' + + 'skipped with a warn naming it, its trigger not armed, while the flows beside it register.', + acceptanceCriteria: + '`objectstack validate` reports no issue at a flow node\'s `config.objectName`: no `create_record`, ' + + '`update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change ' + + 'those nodes made to metadata is made through the metadata API instead. Saving each formerly affected ' + + 'flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, ' + + 'and boot logs no `failed to register flow` warn for it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 59a00370297..ddbb04fd941 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5591,6 +5591,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'it; `os migrate meta --stored` lists each one for review, and `mode: \'inclusive\'` is ' + 'the one-line fix where a node meant every branch.', }, + { + id: 'flow-write-node-stored-metadata-target-refused', + order: 71, + text: + 'It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose ' + + '`objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of ' + + '2026-10-03, letter A, applied to flows: app-authored work may not write those tables, whose only ' + + 'writer is the metadata protocol). The runtime already refused such a node before any write, at its ' + + 'first run, while every authoring door accepted the flow. `FlowSchema` now refuses the same set at ' + + '`nodes.N.config.objectName`, through the one judge `registerFlow` and `objectstack validate` share, ' + + 'with the runtime\'s prescription to change metadata through the metadata API: one of those three ' + + 'write nodes whose `objectName` names either table by exact name. A `get_record` node and a dynamic ' + + 'target stay outside it: the run judges the name it hands the data engine. No key is removed, so there ' + + 'is no tombstone, and no D2 conversion exists: a refused node carries no intent a rewrite could keep. ' + + 'Its D3 record is the semantic entry `flow-write-node-stored-metadata-target-refused`.', + }, { id: 'form-field-public-picker-retired', order: 56, @@ -13109,6 +13125,51 @@ const step18: MigrationStep = { + 'predicate parses and registers byte-identically to before, and a non-string in these ' + 'slots keeps its own earlier refusal (at `registerFlow` and `objectstack validate`).', }, + // #21654 — the D3 entry for `FlowSchema`'s refusal of a write node aimed at a + // stored-metadata table: the save-time half of #21624, which applies #21520's + // ruling A (record 5965059068) to flows, whose run-time half refuses the same + // node before any write. It narrows a flow's accept set; no key is removed, so + // there is no tombstone and no RETIRED_KEYS_BY_MAJOR row. There is no D2 + // conversion either: a refused node carries no intent the chain could rewrite + // into one the runtime runs. + { + id: 'flow-write-node-stored-metadata-target-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span and a table cell. + surface: + 'a create_record, update_record or delete_record flow node whose config.objectName is the string ' + + 'sys_metadata or sys_metadata_history, at any depth including an ADR-0031 region body', + replacement: + 'Change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`, the metadata protocol), ' + + 'where it is validated and its provenance is recorded. Delete the node, or point its `objectName` at ' + + 'the object the flow really means to write. Elevation (`runAs`, a system context) does not change this.', + reason: + '`FlowSchema` accepted a `create_record`, `update_record` or `delete_record` node whose `objectName` ' + + 'names `sys_metadata` or `sys_metadata_history`, the tables that hold stored metadata. The maintainer ' + + 'ruled (2026-10-03) that app-authored work may not write those tables: the metadata protocol is their ' + + 'only writer, where a change is validated and its provenance recorded, and a flow is app-authored ' + + 'automation. The runtime enforces that at the node, refusing the write before it resolves a filter, ' + + 'computes a field or calls the data engine, under every run identity; but every authoring door still ' + + 'accepted such a flow, and the author learned otherwise only at its first run. The parse now refuses ' + + 'it too, through the one judge `FlowSchema.parse`, `AutomationEngine.registerFlow` and ' + + '`objectstack validate` share (`flowNodeConfigRefusals`), with the runtime\'s prescription: ' + + '`objectstack validate`, `defineStack`, compile, an artifact\'s parse, `registerFlow` and the metadata ' + + 'save door each name the node at `nodes.N.config.objectName`. The refused set is exactly the ' + + 'runtime\'s: one of those three write nodes, whose `objectName` is a string naming a stored-metadata ' + + 'table by exact name. A `get_record` node is outside it (a read is not a write), and so is a dynamic ' + + 'target, a `{token}` template or an expression envelope: the parse cannot read it as a name, and the ' + + 'run judges the name it hands the data engine. No authored flow writing either table was measured in this ' + + 'repository, its examples, its skills or its docs. There is no mechanical rewrite: retargeting the ' + + 'node or deleting it each changes what the author wrote, and the runtime already never ran it. Where ' + + 'such a node already sits, the whole flow is refused: registered from `sys_metadata` at boot it is ' + + 'skipped with a warn naming it, its trigger not armed, while the flows beside it register.', + acceptanceCriteria: + '`objectstack validate` reports no issue at a flow node\'s `config.objectName`: no `create_record`, ' + + '`update_record` or `delete_record` node names `sys_metadata` or `sys_metadata_history`. Every change ' + + 'those nodes made to metadata is made through the metadata API instead. Saving each formerly affected ' + + 'flow through the metadata API succeeds instead of answering a 422 that names `config.objectName`, ' + + 'and boot logs no `failed to register flow` warn for it.', + }, // #21180 — ADR-0087 D2, immediate retirement (the maintainer's ruling E on // #21079, comment 5933054144, reversing the #7467 ruling) — the D3 entry of the // `form-field-public-picker-removed` family (ruling B on #17152: one D3 entry From 6d4f21c6c44baac9dee7aeabb16c3ce82106a99f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:19:45 +0000 Subject: [PATCH 2/5] chore(spec): regenerate api-surface and export-origins; changeset for the flow write-node family refusal Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...ite-node-stored-metadata-target-refused.md | 35 +++++++++++++++++++ packages/spec/api-surface/kernel.json | 1 + packages/spec/export-origins/kernel.json | 1 + 3 files changed, 37 insertions(+) create mode 100644 .changeset/21654-flow-write-node-stored-metadata-target-refused.md diff --git a/.changeset/21654-flow-write-node-stored-metadata-target-refused.md b/.changeset/21654-flow-write-node-stored-metadata-target-refused.md new file mode 100644 index 00000000000..affe25ceff7 --- /dev/null +++ b/.changeset/21654-flow-write-node-stored-metadata-target-refused.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +--- + +A flow `create_record`, `update_record` or `delete_record` node whose `objectName` is `sys_metadata` or `sys_metadata_history` is refused at parse, with the runtime's prescription: change metadata through the metadata API. + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +**Why.** App-authored work may not write the two stored-metadata tables: the metadata protocol is their only writer, where a change is validated and its provenance is recorded, and a flow is app-authored automation. The runtime already enforces that at the node: the three write nodes refuse such a target before they resolve a filter, compute a field or call the data engine, under every run identity. But `FlowSchema` still accepted the flow, so `objectstack validate` passed it, the metadata save door answered 200 for it and `registerFlow` registered it, and the author learned otherwise only at its first run. + +**What is refused.** A `create_record`, `update_record` or `delete_record` node, at any depth including an ADR-0031 region body, whose `config.objectName` is a string naming `sys_metadata` or `sys_metadata_history`. The issue's `code` is `custom`, at `nodes.N.config.objectName`, and its message names the node type and the table and ends with the runtime's prescription. The judge is `flowNodeConfigRefusals`, the one `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share, and its membership test is the kernel's own `isStoredMetadataBodyObject`, the predicate the runtime judges by. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.objectName`), `os validate`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). The refusal joins the closed flow slot refusal set as `write-node-stored-metadata-target`, with `params: { nodeType, objectName }`. + +**What stays accepted, byte for byte.** A `get_record` node on those tables (a read is not a write; the runtime judges its reach at the run), a write node whose `objectName` is dynamic (a `{token}` template or an expression envelope: the parse cannot read it as a name, and the runtime judges the name it hands the data engine), and every write node on any other object. + +**One prescription sentence.** `@objectstack/spec/kernel` now exports `STORED_METADATA_BODY_PRESCRIPTION`, the sentence the hook refusal and this flow refusal both end on. It was the hook refusal's private constant, moved unchanged. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| a `create_record` / `update_record` / `delete_record` node with `objectName: 'sys_metadata'` or `objectName: 'sys_metadata_history'` | change metadata through the metadata API (`PUT /api/v1/meta/:type/:name`) instead, and delete the node | +| a write node on any other object, a `get_record` node, or a dynamic `objectName` | unchanged | + +**The one-line fix: delete the node, or point its `objectName` at the object the flow really means to write, and make the metadata change through the metadata API.** The runtime never ran such a write, so removing it changes nothing a flow does. + +**Who is affected, measured.** No authored flow writes either table in this repository's `packages/**`, `examples/**`, `skills/**`, `content/docs/**` or `docs/**` at `417443eb27` (229 write-node declarations); the only hits are the runtime's own tests of its node refusal. Deployed metadata was not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + +### The kit + +- **The refusal.** A third arm of `flowNodeConfigRefusals` (`automation/flow-node-config-refusals.ts`), beside the executor-contract arm and the decision arm. +- **The ledger.** The D3 semantic entry `flow-write-node-stored-metadata-target-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: a refused node carries no intent a rewrite could keep. diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index d552e0de8e7..24b3b0dc5c7 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -375,6 +375,7 @@ "SEMVER_2_0_0_VERSION_PATTERN (const)", "STORED_METADATA_BODY_COLUMN (const)", "STORED_METADATA_BODY_OBJECTS (const)", + "STORED_METADATA_BODY_PRESCRIPTION (const)", "STORED_METADATA_TYPE_COLUMN (const)", "SandboxConfig (type)", "SandboxConfigParsed (type)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index a0e8901802e..3db69aff7fe 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -373,6 +373,7 @@ "SEMVER_2_0_0_VERSION_PATTERN": "src/kernel/version-grammar.ts#SEMVER_2_0_0_VERSION_PATTERN (const)", "STORED_METADATA_BODY_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_BODY_COLUMN (const)", "STORED_METADATA_BODY_OBJECTS": "src/kernel/stored-metadata-body-objects.ts#STORED_METADATA_BODY_OBJECTS (const)", + "STORED_METADATA_BODY_PRESCRIPTION": "src/kernel/stored-metadata-body-objects.ts#STORED_METADATA_BODY_PRESCRIPTION (const)", "STORED_METADATA_TYPE_COLUMN": "src/kernel/metadata-type-redaction.ts#STORED_METADATA_TYPE_COLUMN (const)", "SandboxConfig": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfig (type)", "SandboxConfigParsed": "src/kernel/plugin-security-advanced.zod.ts#SandboxConfigParsed (type)", From 8f5adb6ad6b70d1ae111fce258e0628f2c15fe7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:33:01 +0000 Subject: [PATCH 3/5] chore(spec): the flow write-node rationale fragment takes order 74, the next free after main's 71-73 Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index b64bd06a6b3..ab422f3d34a 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5593,7 +5593,7 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ }, { id: 'flow-write-node-stored-metadata-target-refused', - order: 71, + order: 74, text: 'It also refuses, at parse, a flow `create_record`, `update_record` or `delete_record` node whose ' + '`objectName` is the string `sys_metadata` or `sys_metadata_history` (the maintainer ruling of ' From 74ec64710f56ad4ebb5523b0da2c2411c29eedab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:34:36 +0000 Subject: [PATCH 4/5] test(spec): the stack parse objectstack validate runs refuses a family-target write node at its path Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../flow-write-node-stored-metadata-target.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts b/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts index 9fab850a21e..ba1e7ea5c03 100644 --- a/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts +++ b/packages/spec/src/automation/flow-write-node-stored-metadata-target.test.ts @@ -30,7 +30,7 @@ import { import * as leaf from '../kernel/stored-metadata-body-objects'; import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; -import { ArtifactStagePackageBodySchema, defineStack } from '../stack.zod'; +import { ArtifactStagePackageBodySchema, ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; import { HookSchema } from '../data/hook.zod'; import { flowNodeConfigRefusals } from './flow-node-config-refusals'; import { FlowSchema } from './flow.zod'; @@ -171,6 +171,14 @@ describe('every door that parses a flow refuses it', () => { expect(() => defineStack(stackWith([named('fws_ok', 'fws_note')]) as never)).not.toThrow(); }); + it('ObjectStackDefinitionSchema — the stack parse `objectstack validate` runs — refuses it at the same path', () => { + const refused = ObjectStackDefinitionSchema.safeParse(stackWith([named('fws_family', 'sys_metadata')])); + expect(refused.success).toBe(false); + expect(refused.success ? [] : refused.error.issues.map((i) => i.path.join('.'))).toEqual(['flows.0.nodes.1.config.objectName']); + // CONTROL: the same parse accepts the ordinary target. + expect(ObjectStackDefinitionSchema.safeParse(stackWith([named('fws_ok', 'fws_note')])).success).toBe(true); + }); + it('the registered `flow` type schema — what the metadata save door validates against — refuses it too', () => { const schema = getMetadataTypeSchema('flow') as unknown as typeof FlowSchema; expect(schema).toBeDefined(); From a9d2d5d453d51b67cedda80947c7b3e4caedd490 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:58:00 +0000 Subject: [PATCH 5/5] test(service-automation): the run-time family pins assert the save-time refusal first, then run a definition the parse never judged Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- ...etadata-family-refusal.integration.test.ts | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts index 96c990e1b31..b1f916a4c80 100644 --- a/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts @@ -33,6 +33,15 @@ * a flow variable leaves the family table unchanged, and the three nodes write * an ordinary object exactly as before. * + * [#21654] The save-time half. `FlowSchema` now refuses a write node whose + * STATIC `objectName` names a family table, and `registerFlow` parses first, + * so a flow carrying one is refused before it can run. Every static case here + * therefore asserts that refusal first (`registerFlow` throws, the issue sits at + * the node's `config.objectName`, the table is unchanged), and only then reaches + * the run-time guard, with a definition the parse never judged: see + * {@link registerForRun}. A dynamic target (`{record.target}`) is not judged at + * save and registers as before. + * * Composition: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on * better-sqlite3 `:memory:` and the real `AutomationServicePlugin`, the stack * the family read pins boot; the secured composition adds the real @@ -60,6 +69,49 @@ type RunAs = 'system' | 'user'; const STORED_BODY = JSON.stringify({ name: 'pin_body', label: 'Pin body' }); const BODY_FRAGMENT = '"label":"Pin body"'; +/** + * [#21654] The target a static family case is registered under, so that the + * parse lets it through; {@link registerForRun} then puts the family table back + * on the registered definition. No object of this name exists: a definition + * whose retarget did not land fails its run with a not-found error, never with + * the family refusal its case asserts. + */ +const STAND_IN_TARGET = 'pin_stand_in_target'; + +/** One write node in a flow definition aimed at a family table, and where it sits. */ +interface FamilyTarget { + readonly path: string; + readonly object: string; +} + +/** + * Every write node in `def`, at any depth (a `try_catch` region's nodes + * included), whose `config.objectName` is `match` — or, with no `match`, is a + * family table by name. Paths in the parse's dotted spelling + * (`nodes.1.config.objectName`). + */ +function writeTargetsIn(def: unknown, match?: string): Array }> { + const found: Array }> = []; + const visit = (value: unknown, path: string[]): void => { + if (Array.isArray(value)) { + value.forEach((item, i) => visit(item, [...path, String(i)])); + return; + } + if (value === null || typeof value !== 'object') return; + const rec = value as Record; + const config = rec.config as Record | undefined; + if ((WRITE_NODES as readonly unknown[]).includes(rec.type) && config && typeof config.objectName === 'string') { + const object = config.objectName; + if (match === undefined ? (FAMILY as readonly string[]).includes(object) : object === match) { + found.push({ path: [...path, 'config', 'objectName'].join('.'), object, node: config }); + } + } + for (const [key, child] of Object.entries(rec)) visit(child, [...path, key]); + }; + visit(def, []); + return found; +} + /** An ordinary object: the non-family control. */ const PLAIN_OBJECT = { name: 'pin_write_plain', @@ -169,9 +221,71 @@ function harness(ql: ObjectQL, automation: AutomationEngine) { return { objectName: object, filter: { id } }; } + /** + * [#21654] Register `def` so that it can RUN. A definition with no static + * family target registers as it always did. One that carries such a target is + * refused by the parse at save, now that `FlowSchema` judges it, so this first + * asserts that refusal — `registerFlow` throws, the issue is a `custom` one at + * each such node's `config.objectName` carrying the metadata-protocol + * prescription, nothing is registered under the name, and the target table is + * unchanged — and only then reaches the run-time guard with a definition the + * parse never judged: the same definition registered with + * {@link STAND_IN_TARGET} in place of each family table, after which the + * family table is put back on the definition the engine holds. + * + * The engine behaviour this leans on, none of which the save-time refusal + * changes: `registerFlow` stores the parsed definition it returns, by + * reference (`this.flows.set(name, parsed)`, then `return parsed`), and + * `execute` runs `this.flows.get(name)` as stored, never re-parsing it. Both + * are read back here rather than assumed: `getFlow(name)` must answer the + * family table at every retargeted path before the run. Were either to stop + * holding — a copy, a freeze, a re-parse — the retarget would fail to land, + * that read-back would go red, and the run would refuse nothing for the family + * reason; a frozen definition throws on the write itself. + */ + async function registerForRun(def: { name: string }): Promise { + const targets = writeTargetsIn(def); + if (targets.length === 0) { + automation.registerFlow(def.name, def as any); + return; + } + + // Save time: refused, located at each family target, nothing registered, the table unchanged. + const tables = [...new Set(targets.map((t) => t.object))]; + const before = await Promise.all(tables.map((object) => snapshot(object))); + let thrown: { issues?: Array<{ code: string; path: PropertyKey[]; message: string }> } | undefined; + try { + automation.registerFlow(def.name, def as any); + } catch (err) { + thrown = err as typeof thrown; + } + expect(thrown, `${def.name}: registerFlow must refuse a static family target at save`).toBeDefined(); + expect( + (thrown!.issues ?? []).map((i) => ({ code: i.code, path: i.path.join('.') })), + `${def.name}: the save-time refusal's issues`, + ).toEqual(targets.map((t) => ({ code: 'custom', path: t.path }))); + for (const issue of thrown!.issues ?? []) expect(issue.message).toContain('the metadata protocol'); + expect(await automation.getFlow(def.name), `${def.name}: a refused flow was registered`).toBeNull(); + expect(await Promise.all(tables.map((object) => snapshot(object))), `${def.name}: the save-time refusal changed a table`) + .toEqual(before); + + // Run time: a definition the parse never judged — registered aimed at the stand-in, then retargeted. + const standIn = JSON.parse(JSON.stringify(def)) as { name: string }; + for (const target of writeTargetsIn(standIn)) target.node.objectName = STAND_IN_TARGET; + const registered = automation.registerFlow(def.name, standIn as any); + const placeholders = writeTargetsIn(registered, STAND_IN_TARGET); + expect(placeholders.map((p) => p.path), `${def.name}: the stand-in sits where the family targets did`) + .toEqual(targets.map((t) => t.path)); + placeholders.forEach((placeholder, i) => { + placeholder.node.objectName = targets[i]!.object; + }); + expect(writeTargetsIn(await automation.getFlow(def.name)), `${def.name}: the engine holds the retargeted definition`) + .toEqual(targets.map((t) => expect.objectContaining({ path: t.path, object: t.object }))); + } + /** Run `def` with the engine's write verbs watched; count the calls aimed at the family. */ async function runWatched(def: { name: string }, trigger: Record) { - automation.registerFlow(def.name, def as any); + await registerForRun(def); const insert = vi.spyOn(ql, 'insert'); const update = vi.spyOn(ql, 'update'); const remove = vi.spyOn(ql, 'delete'); @@ -193,7 +307,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) { async function codeAsAFlowReadsIt(runAs: RunAs, node: Record, trigger: Record) { const name = `pin_code_${seq++}`; captured.length = 0; - automation.registerFlow(name, { + await registerForRun({ name, label: name, type: 'autolaunched', runAs, nodes: [ { id: 'start', type: 'start', label: 'Start' }, @@ -208,7 +322,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) { { id: 'end', type: 'end', label: 'End' }, ], edges: [{ id: 'e1', source: 'start', target: 'guarded' }, { id: 'e2', source: 'guarded', target: 'end' }], - } as any); + } as { name: string }); await automation.execute(name, { ...trigger } as any); expect(captured, 'the catch region must have run once').toHaveLength(1); return captured[0]!;