From 50d200a3c15ea8f8699259dbe17b21c8fabbf85d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:00:27 +0000 Subject: [PATCH 1/6] wip(spec): list the v18 stored shapes refused with no lossless rewrite through the TODO sink Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- packages/spec/src/conversions/apply.ts | 19 +- .../src/conversions/refused-shapes.test.ts | 334 ++++++++++++++++++ .../spec/src/conversions/refused-shapes.ts | 300 ++++++++++++++++ packages/spec/src/conversions/stored.ts | 5 +- packages/spec/src/conversions/types.ts | 13 +- .../src/data/controlled-by-parent-master.ts | 135 +++++++ packages/spec/src/data/object.zod.ts | 59 +--- 7 files changed, 813 insertions(+), 52 deletions(-) create mode 100644 packages/spec/src/conversions/refused-shapes.test.ts create mode 100644 packages/spec/src/conversions/refused-shapes.ts create mode 100644 packages/spec/src/data/controlled-by-parent-master.ts diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index 239d77452a5..128ee27f419 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -13,6 +13,7 @@ */ import { ALL_CONVERSIONS } from './registry.js'; +import { reportRefusedStoredShapes } from './refused-shapes.js'; import { CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, @@ -75,9 +76,16 @@ export interface ApplyConversionsOptions { * the sink only makes it visible. * * Read today by the stored-metadata migration pass (`os migrate meta - * --stored`, through `applyConversionsToStoredItem`), which lists each TODO - * under its row. The authoring funnel and the other data-at-rest seams do not - * pass one. + * --stored`, through `applyConversionsToStoredItem` and, for a flow row, the + * automation engine's `canonicalizeStoredFlow`), which lists each TODO under + * its row. The authoring funnel and the other data-at-rest seams do not pass + * one. + * + * A sink also turns on the refused-shape listing (#23269, #23270, #23271): + * after the conversions, every shape protocol 18 refuses with no lossless + * rewrite (`refused-shapes.ts`) is reported here too, one TODO per site, + * its `conversionId` naming the D3 entry that refuses it. With no sink + * nothing of it runs, and in either case nothing is rewritten. */ onTodo?: (todo: ConversionTodoNotice) => void; /** @@ -199,6 +207,11 @@ export function applyConversions( ); } + // [#23269, #23270, #23271] What protocol 18 refuses and nothing converts, + // judged on the converted stack and listed through the same sink: a row the + // chain leaves refused must not read as already on protocol. + if (onTodo) reportRefusedStoredShapes(current, onTodo); + return current; } diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts new file mode 100644 index 00000000000..c8281ce237a --- /dev/null +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js'; +import { ObjectSchema } from '../data/object.zod.js'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js'; +import { ViewDataSchema } from '../ui/view.zod.js'; +import { applyConversions, applyConversionsToFlow } from './apply.js'; +import { REFUSED_STORED_SHAPES } from './refused-shapes.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js'; + +/** + * [#23269 · #23270 · #23271] The v18 upgrade's fate for stored rows of three + * retired shapes, each with no lossless rewrite: REFUSED AND LISTED. + * + * The listing rides the conversion pass's TODO sink, so `os migrate meta + * --stored` prints each site under its row. These pins hold the spec half: + * - each refused shape is listed once per site, with its row's name, the node + * or view it sits on, and its fate; + * - a conforming row, a converted row and a row on another arm are not listed; + * - the listing agrees with the refusing door, in both directions; + * - nothing is rewritten, with or without a sink. + */ + +/** The retired view-level provider, spelled through a constant (the tree-scoped absence pin reads literals). */ +const API = 'api' as const; + +function todosOf(stack: Record): { stack: Record; todos: ConversionTodoNotice[] } { + const todos: ConversionTodoNotice[] = []; + const out = applyConversions(stack, { includeRetired: true, onTodo: (t) => todos.push(t) }); + return { stack: out, todos }; +} + +function storedTodos(type: string, row: Record) { + const todos: ConversionTodoNotice[] = []; + const notices: ConversionNotice[] = []; + const item = applyConversionsToStoredItem(type, row, { + onTodo: (t) => todos.push(t), + onNotice: (n) => notices.push(n), + }) as Record; + return { item, todos, notices }; +} + +describe('the refused-shape list — registered against the migration chain', () => { + it('names each refused shape by a D3 entry of its step, never by a conversion id', () => { + const conversionIds = new Set(ALL_CONVERSIONS.map((c) => c.id)); + const ids = REFUSED_STORED_SHAPES.map((r) => r.id); + expect(new Set(ids).size, 'ids are unique').toBe(ids.length); + for (const refused of REFUSED_STORED_SHAPES) { + const step = MIGRATIONS_BY_MAJOR[refused.toMajor]; + expect(step?.semantic.map((s) => s.id), `${refused.id} is a D3 entry of step ${refused.toMajor}`) + .toContain(refused.id); + expect(conversionIds.has(refused.id), `${refused.id} is not a conversion id`).toBe(false); + } + expect(ids).toEqual([ + 'cbp-master-relation-tiers-retired', + 'view-data-api-provider-retired', + 'flow-value-slot-template-dialect-refused', + ]); + }); + + it('runs only with a TODO sink, and never rewrites either way', () => { + const stack = { + objects: [{ name: 'crm_line', label: 'Line', sharingModel: 'controlled_by_parent', fields: {} }], + }; + expect(applyConversions(stack, { includeRetired: true })).toBe(stack); + const { stack: out, todos } = todosOf(stack); + expect(out).toBe(stack); + expect(todos).toHaveLength(1); + expect(todos[0]!.code).toBe(CONVERSION_TODO_CODE); + }); +}); + +// ── #23270 ──────────────────────────────────────────────────────────────── + +const cbpRow = (name: string, fields: Record, sharingModel = 'controlled_by_parent') => ({ + name, + label: name, + sharingModel, + fields: { title: { type: 'text', label: 'Title' }, ...fields }, +}); +const md = (reference: string, extra: Record = {}) => ({ + type: 'master_detail', + label: reference, + reference, + ...extra, +}); + +/** The parse's controlled_by_parent issue on one object, or undefined. */ +function cbpIssue(row: unknown) { + const r = ObjectSchema.safeParse(row); + if (r.success) return undefined; + return r.error.issues.find((i) => /declares sharingModel 'controlled_by_parent'/.test(i.message)); +} + +describe('#23270 — a stored controlled_by_parent object without exactly one required master is listed by object', () => { + const SHAPES: ReadonlyArray<[string, Record, string, RegExp]> = [ + [ + 'two masters, both required', + { quote: md('crm_quote', { required: true }), order: md('crm_order', { required: true }) }, + 'objects[0].fields', + /declares 2 `master_detail` fields \(`quote`, `order`\), and which of them is the master is the owner's choice/, + ], + [ + 'two masters, neither required', + { quote: md('crm_quote'), order: md('crm_order') }, + 'objects[0].sharingModel', + /declares 2 `master_detail` fields \(`quote`, `order`\), and which of them is the master is the owner's choice/, + ], + [ + 'an explicit required: false', + { quote: md('crm_quote', { required: false }) }, + 'objects[0].sharingModel', + /`quote`, declares `required: false`, which the builder refuses rather than forcing/, + ], + [ + 'a required lookup only', + { quote: { type: 'lookup', label: 'Quote', reference: 'crm_quote', required: true } }, + 'objects[0].sharingModel', + /`quote` is a required `lookup`, which no longer derive a master\. Turning a `lookup` into a `master_detail` changes what deleting the master does/, + ], + [ + 'no master', + { note: { type: 'text', label: 'Note' } }, + 'objects[0].sharingModel', + /declares no `master_detail` and no required `lookup`, so there is no master to derive/, + ], + ]; + + it.each(SHAPES)('%s: one TODO naming the object, its shape and the owner\'s choice; the row is not rewritten', (_label, fields, path, fate) => { + const row = cbpRow('crm_line', fields); + const { item, todos, notices } = storedTodos('object', row); + expect(notices.filter((n) => n.conversionId === 'cbp-master-detail-required-defaulted')).toEqual([]); + expect(item).toEqual(row); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe('cbp-master-relation-tiers-retired'); + expect(todo.path).toBe(path); + expect(todo.reason).toMatch(fate); + expect(todo.reason).toContain("Object 'crm_line' declares sharingModel 'controlled_by_parent'"); + expect(todo.reason).toContain('every read of its rows is denied'); + expect(todo.reason).toMatch(/give it its own sharingModel \('private', 'public_read' or 'public_read_write'\), which changes who can read them/); + // The listing and the parse refuse ONE set: the stored row still fails the parse, at the same key. + expect(cbpIssue(item)?.path).toEqual([path.split('.').pop()]); + // The relation is shown; the unrelated field is not. + expect(JSON.parse(todo.from).fields.title).toBeUndefined(); + }); + + it('the Studio shape — one master_detail, `required` omitted — converts and is NOT listed', () => { + const row = cbpRow('crm_line', { opportunity: md('crm_opportunity') }); + const { item, todos, notices } = storedTodos('object', row); + expect(notices.map((n) => n.conversionId)).toContain('cbp-master-detail-required-defaulted'); + expect(item.fields.opportunity.required).toBe(true); + expect(todos).toEqual([]); + expect(cbpIssue(item)).toBeUndefined(); + }); + + it.each([ + ['one required master', { opportunity: md('crm_opportunity', { required: true }) }], + ['one required master beside a second master_detail without required', { + opportunity: md('crm_opportunity', { required: true }), + quote: md('crm_quote'), + }], + ['one required master beside a required lookup', { + opportunity: md('crm_opportunity', { required: true }), + campaign: { type: 'lookup', label: 'Campaign', reference: 'crm_campaign', required: true }, + }], + ])('a conforming object (%s) is untouched and not listed — and the parse accepts it', (_label, fields) => { + const row = cbpRow('crm_line', fields); + const { item, todos } = storedTodos('object', row); + expect(item).toBe(row); + expect(todos).toEqual([]); + expect(cbpIssue(row)).toBeUndefined(); + }); + + it('another sharing model is not listed, whatever its relations', () => { + const row = cbpRow('crm_line', { quote: md('crm_quote'), order: md('crm_order') }, 'private'); + expect(storedTodos('object', row).todos).toEqual([]); + }); +}); + +// ── #23271 ──────────────────────────────────────────────────────────────── + +const apiData = () => ({ + provider: API, + read: { url: 'https://partner.example.com/cases', method: 'GET', headers: { Authorization: 'Bearer s3cr3t' } }, + write: { url: '/api/v1/cases', method: 'POST' }, +}); + +describe('#23271 — a stored view bound to the retired view-level API source is listed by view', () => { + const PRESCRIPTION = /`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18/; + + it.each([ + ['a container\'s list', { object: 'crm_case', list: { type: 'grid', columns: ['name'], data: apiData() } }, 'views[0].list.data'], + ['a container\'s form', { object: 'crm_case', form: { type: 'simple', data: apiData() } }, 'views[0].form.data'], + ['a container\'s named list view', { + object: 'crm_case', + list: { type: 'grid', columns: ['name'] }, + listViews: { open: { type: 'grid', columns: ['name'], data: apiData() } }, + }, 'views[0].listViews.open.data'], + ['a view item record', { + name: 'open_cases', object: 'crm_case', viewKind: 'list', + config: { type: 'grid', columns: ['name'], data: apiData() }, + }, 'views[0].config.data'], + ['a flattened overlay', { + name: 'open_cases', object: 'crm_case', viewKind: 'list', type: 'grid', columns: ['name'], data: apiData(), + }, 'views[0].data'], + ])('%s: one TODO at the data block, with its fate and the prescription; the row is not rewritten', (_label, row, path) => { + const { item, todos } = storedTodos('view', row as Record); + expect(item).toEqual(row); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe('view-data-api-provider-retired'); + expect(todo.path).toBe(path); + expect(todo.reason).toMatch(/No mechanical move: the replacement is a `dataSource` binding on a page component/); + expect(todo.reason).toMatch(PRESCRIPTION); + }); + + it('an object\'s own list view is listed under the object row', () => { + const row = { + name: 'crm_case', + label: 'Case', + fields: { name: { type: 'text', label: 'Name' } }, + listViews: { open: { type: 'grid', columns: ['name'], data: apiData() } }, + }; + const { todos } = storedTodos('object', row); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['view-data-api-provider-retired', 'objects[0].listViews.open.data'], + ]); + expect(todos[0]!.reason).toContain('The list view `open` of object `crm_case`'); + }); + + it('never prints a credential: the URL and the method are shown, every other request key is withheld', () => { + const { todos } = storedTodos('view', { object: 'crm_case', list: { type: 'grid', data: apiData() } }); + const todo = todos[0]!; + expect(JSON.parse(todo.from)).toEqual({ + provider: 'api', + read: { url: 'https://partner.example.com/cases', method: 'GET', headers: '[withheld]' }, + write: { url: '/api/v1/cases', method: 'POST' }, + }); + expect(`${todo.from} ${todo.reason} ${todo.message}`).not.toContain('s3cr3t'); + }); + + it.each([ + ['object', { provider: 'object', object: 'crm_case' }], + ['value', { provider: 'value', items: [{ name: 'a' }] }], + ['schema', { provider: 'schema', schemaId: 'report' }], + ])('a view on the `%s` provider is untouched and not listed', (_provider, data) => { + const row = { object: 'crm_case', list: { type: 'grid', columns: ['name'], data } }; + const { item, todos } = storedTodos('view', row); + expect(item).toBe(row); + expect(todos).toEqual([]); + expect(ViewDataSchema.safeParse(data).success).toBe(true); + }); + + it('agrees with the refusing door: the binding it lists is one ViewDataSchema refuses', () => { + expect(ViewDataSchema.safeParse(apiData()).success).toBe(false); + }); +}); + +// ── #23269 ──────────────────────────────────────────────────────────────── + +const crudNode = (id: string, type: string, filter: unknown) => ({ + id, type, label: id, config: { objectName: 'crm_case', filter, ...(type === 'update_record' ? { fields: { status: 'closed' } } : {}) }, +}); +const flowOf = (name: string, nodes: unknown[]) => ({ + name, + label: name, + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, ...nodes, { id: 'end', type: 'end', label: 'End' }], + edges: [], +}); + +describe('#23269 — a stored flow whose CRUD filter operand is a {…} token is listed by flow and node', () => { + it.each([ + ['get_record', { id: '{record.id}' }, 'config.filter.id', '{record.id}'], + ['update_record', { amount: { $gt: '{record.cap}' } }, 'config.filter.amount.$gt', '{record.cap}'], + ['delete_record', { id: { $in: ['{a}', 'literal'] } }, 'config.filter.id.$in[0]', '{a}'], + ])('%s: one TODO naming the flow, the node and the operand, with its CEL envelope; the flow is not rewritten', (type, filter, at, source) => { + const flow = flowOf('fd_refused_filter', [crudNode('crud', type, filter)]); + const todos: ConversionTodoNotice[] = []; + const out = applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) }); + expect(out).toBe(flow); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe('flow-value-slot-template-dialect-refused'); + expect(todo.path).toBe(`flows[0].nodes[1].${at}`); + expect(todo.from).toBe(JSON.stringify(source)); + expect(todo.reason).toContain(`Flow \`fd_refused_filter\`, node 'crud' (${type}), ${type} filter operand at ${at}.`); + expect(todo.reason).toContain('registerFlow refuses the flow: it is not registered at boot and never runs'); + expect(todo.reason).toContain("{ dialect: 'cel', source: '"); + }); + + it('reaches a node inside a structured region', () => { + const flow = flowOf('fd_loop', [{ + id: 'each', type: 'loop', label: 'Each', + config: { + collection: { dialect: 'cel', source: 'items' }, + body: { nodes: [crudNode('inner', 'get_record', { id: '{item.id}' })], edges: [] }, + }, + }]); + const todos: ConversionTodoNotice[] = []; + applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) }); + expect(todos.map((t) => t.path)).toEqual(['flows[0].nodes[1].config.body.nodes[0].config.filter.id']); + expect(todos[0]!.reason).toContain("node 'inner' (get_record)"); + }); + + it.each([ + ['a CEL envelope', { id: { dialect: 'cel', source: 'record.id' } }], + ['a query placeholder', { owner: '{current_user_id}', due: { $lt: '{today}' } }], + ['a literal', { status: 'open' }], + ])('a flow already on protocol (%s) is untouched and not listed', (_label, filter) => { + const flow = flowOf('fd_canonical', [crudNode('crud', 'get_record', filter)]); + const todos: ConversionTodoNotice[] = []; + const out = applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) }); + expect(out).toBe(flow); + expect(todos).toEqual([]); + }); + + it('agrees with the refusing door: each site is one the value-slot judge refuses, and no other', () => { + const nodes = [ + crudNode('a', 'get_record', { id: '{record.id}', owner: '{current_user_id}' }), + crudNode('b', 'delete_record', { id: { dialect: 'cel', source: 'record.id' } }), + ]; + const { todos } = todosOf({ flows: [flowOf('fd_mixed', nodes)] }); + const judged = nodes.flatMap((n, i) => + flowNodeValueTemplateRefusals(n.type, n.config).map((r) => `flows[0].nodes[${i + 1}].config.${r.path}`)); + expect(todos.map((t) => t.path)).toEqual(judged); + expect(judged).toEqual(['flows[0].nodes[1].config.filter.id']); + }); +}); diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts new file mode 100644 index 00000000000..d1dd43920aa --- /dev/null +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -0,0 +1,300 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Stored shapes protocol 18 refuses, and no conversion rewrites — LISTED, never + * rewritten (#23269, #23270, #23271). + * + * ADR-0087's model is "convert where lossless, a structured TODO otherwise — + * never silence". A D2 conversion carries the lossless half. For the shapes + * below no lossless rewrite exists, so there is no conversion and, until this + * list, nothing named them to the operator before the upgrade. The stored + * preview (`os migrate meta --stored`) listed the rows the chain converts. It + * read a row the parse or the boot then refuses as already on protocol. + * + * Each entry here detects one retirement's refused shape in a stack. Its `id` + * is the id of that retirement's D3 semantic entry on the step it belongs to, + * so the preview's TODO line, the upgrade guide's entry and this detector share + * one name. `refused-shapes.test.ts` refuses an id with no step entry. + * + * ## How it is read + * + * {@link reportRefusedStoredShapes} runs at the end of `applyConversions`, + * over the stack the conversions produced, and ONLY when the caller passes an + * `onTodo` sink. Two callers pass one: the stored-metadata migration pass + * (through `applyConversionsToStoredItem`), and the automation engine's + * `canonicalizeStoredFlow`, which that pass calls for a flow row. Each finding + * reaches the sink as a {@link ConversionTodoNotice}, under its row, exactly + * like a site a conversion left as stored. The authoring funnel and the read + * seams pass no sink, so for them nothing here runs. + * + * ## What it never does + * + * - It never rewrites. A detector returns nothing. The stack it reads is not + * touched, and the shape stays as stored. + * - It never decides. The predicate is the refusing door's own: the + * `controlled_by_parent` refinement's + * ({@link controlledByParentMasterRefusal}), `ViewDataSchema`'s parse, and + * the value-slot judge `AutomationEngine.registerFlow` and `objectstack + * validate` run ({@link flowNodeValueTemplateRefusals}). A site is listed + * here exactly when that door refuses it. + * - It never prints a credential. A view-level API source could carry request + * `headers`. The listing names the URL and the method, and withholds the rest. + * + * ⚠️ Internal to `packages/spec`: not re-exported from `conversions/index.ts`. + * The TODO notices it produces are the published shape. + */ + +import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js'; +import { + controlledByParentMasterRefusal, + type ControlledByParentMasterRefusal, +} from '../data/controlled-by-parent-master.js'; +import { ViewDataSchema } from '../ui/view.zod.js'; +import { + CONVERSION_TODO_CODE, + type ConversionTodoDetail, + type ConversionTodoNotice, +} from './types.js'; +import { mapCollection, mapFlowNodeList, mapViewPayloads } from './walk.js'; + +type Dict = Record; + +function isDict(v: unknown): v is Dict { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * One retirement's refused stored shape: a detector, named by the D3 semantic + * entry it belongs to. + */ +export interface RefusedStoredShape { + /** The id of the step's D3 semantic entry (`MIGRATIONS_BY_MAJOR[toMajor].semantic[].id`). */ + readonly id: string; + /** The protocol major whose step carries that entry. */ + readonly toMajor: number; + /** Dotted surface, as a TODO notice names it. */ + readonly surface: string; + /** + * Report every refused site in `stack`, once each. Reads only: returns + * nothing and never mutates `stack`. + */ + detect(stack: Dict, report: (detail: ConversionTodoDetail) => void): void; +} + +// ── #23270: a controlled_by_parent object without exactly one required master ── + +/** The listed name of one field, or of a list of them. */ +function fieldList(names: readonly string[]): string { + return names.map((n) => `\`${n}\``).join(', '); +} + +/** + * The fate of each refused shape, as the TODO states it: why the upgrade does + * not rewrite it, and what the owner chooses between. The parse's message + * carries the refusal itself and follows this sentence. + */ +function controlledByParentFate(refusal: ControlledByParentMasterRefusal, fields: Dict): string { + const ownSharingModel = + "or, if no parent should govern its rows, give it its own sharingModel ('private', 'public_read' " + + "or 'public_read_write'), which changes who can read them"; + switch (refusal.shape) { + case 'several-required-masters': + case 'several-masters': + return `It declares ${refusal.masterDetails.length} \`master_detail\` fields ` + + `(${fieldList(refusal.masterDetails)}), and which of them is the master is the owner's choice: ` + + 'the upgrade never picks one. Keep `master_detail` with `required: true` on the master and ' + + `declare each other parent as a \`lookup\`, ${ownSharingModel}.`; + case 'master-not-required': { + const name = refusal.masterDetails[0]!; + const def = fields[name]; + const written = isDict(def) && 'required' in def ? JSON.stringify(def.required) : 'nothing'; + return `Its one \`master_detail\`, \`${name}\`, declares \`required: ${written}\`, which the builder ` + + 'refuses rather than forcing, so the upgrade does not overrule it. Declare `required: true` on it, ' + + `${ownSharingModel}.`; + } + case 'required-lookup-only': + return `It declares no \`master_detail\`, and ${fieldList(refusal.requiredLookups)} ` + + `${refusal.requiredLookups.length === 1 ? 'is a required `lookup`' : 'are required `lookup` fields'}, ` + + 'which no longer derive a master. Turning a `lookup` into a `master_detail` changes what deleting ' + + 'the master does to every row, so the upgrade does not write it. Declare the master ' + + "`type: 'master_detail'` with `required: true` (`deleteBehavior: 'restrict'` refuses deleting a " + + `master that still has rows), ${ownSharingModel}.`; + case 'no-master': + return 'It declares no `master_detail` and no required `lookup`, so there is no master to derive ' + + 'and nothing to convert. Add a `master_detail` field with `required: true` referencing the master ' + + `object, ${ownSharingModel}.`; + } +} + +const controlledByParentWithoutOneMaster: RefusedStoredShape = { + id: 'cbp-master-relation-tiers-retired', + toMajor: 18, + surface: "object.fields — the master relation of a sharingModel: 'controlled_by_parent' object", + detect(stack, report) { + mapCollection(stack, 'objects', (obj, path) => { + const refusal = controlledByParentMasterRefusal(obj.name, obj.sharingModel, obj.fields); + if (refusal === undefined) return obj; + const fields = obj.fields as Dict; + const relation: Dict = {}; + for (const name of [...refusal.masterDetails, ...refusal.requiredLookups]) relation[name] = fields[name]; + report({ + path: `${path}.${refusal.path}`, + from: JSON.stringify({ sharingModel: obj.sharingModel, fields: relation }), + reason: 'No lossless rewrite: protocol 18 derives a controlled-by-parent object\'s master from exactly ' + + 'one `master_detail` declared `required: true`, and this object does not declare exactly one. Left as stored, ' + + 'it registers under `[metadata_spec_invalid]` and the platform derives no master: every read of ' + + 'its rows is denied and every write refused with 422 INVALID_METADATA. ' + + `${controlledByParentFate(refusal, fields)} ${refusal.message}`, + }); + return obj; + }); + }, +}; + +// ── #23271: a view whose data source is the retired view-level API binding ── + +/** The request keys the listing names; every other key of a request block is withheld. */ +const REQUEST_KEYS_SHOWN = ['url', 'method'] as const; + +/** + * The binding as the TODO shows it: `provider`, and each request block's `url` + * and `method`. Any other key (`headers`, `params`, `body`, …) is replaced by + * the marker `[withheld]`, so a credential stored in the view never reaches + * the report. + */ +function describeApiBinding(data: Dict): string { + const shown: Dict = {}; + for (const [key, value] of Object.entries(data)) { + if (key === 'provider') { + shown[key] = value; + continue; + } + if ((key === 'read' || key === 'write') && isDict(value)) { + const request: Dict = {}; + for (const [k, v] of Object.entries(value)) { + request[k] = (REQUEST_KEYS_SHOWN as readonly string[]).includes(k) ? v : '[withheld]'; + } + shown[key] = request; + continue; + } + shown[key] = '[withheld]'; + } + return JSON.stringify(shown); +} + +/** + * The prescription `ViewDataSchema` refuses this binding with, or `undefined` + * when the schema does not refuse it — the one judge of whether the site is + * listed. + */ +function apiBindingRefusal(data: unknown): string | undefined { + if (!isDict(data) || data.provider !== 'api') return undefined; + const verdict = ViewDataSchema.safeParse(data); + if (verdict.success) return undefined; + const issue = verdict.error.issues.find((i) => i.path.length === 1 && i.path[0] === 'provider') + ?? verdict.error.issues[0]; + return issue?.message; +} + +const VIEW_API_BINDING_FATE = + 'No mechanical move: the replacement is a `dataSource` binding on a page component, so a page must exist ' + + 'to carry it, and the view stays a view. The one edit a pass could make, deleting the view\'s `data`, ' + + 'would make the view query its object instead and change what it shows. Left as stored, the parse ' + + 'refuses the view: it registers under `[metadata_spec_invalid]` and its next save is refused with 422. ' + + 'Decide which page composes the endpoint, and what the view itself binds now.'; + +const viewDataApiProvider: RefusedStoredShape = { + id: 'view-data-api-provider-retired', + toMajor: 18, + surface: 'view.data — a list or form view\'s data source, in a view body or an object\'s listViews', + detect(stack, report) { + const judge = (payload: Dict, path: string, where: string): void => { + const prescription = apiBindingRefusal(payload.data); + if (prescription === undefined) return; + report({ + path: `${path}.data`, + from: describeApiBinding(payload.data as Dict), + reason: `${where} binds the retired view-level API data source. ${VIEW_API_BINDING_FATE} ${prescription}`, + }); + }; + mapViewPayloads(stack, (payload, kind, path) => { + judge(payload, path, `The ${kind} view at ${path}`); + return payload; + }); + mapCollection(stack, 'objects', (obj, path) => { + const listViews = obj.listViews; + if (!isDict(listViews)) return obj; + for (const [name, entry] of Object.entries(listViews)) { + if (isDict(entry)) judge(entry, `${path}.listViews.${name}`, `The list view \`${name}\` of object \`${String(obj.name)}\``); + } + return obj; + }); + }, +}; + +// ── #23269: a flow value slot carrying a `{…}` token, a CRUD filter operand among them ── + +const flowValueSlotTemplateToken: RefusedStoredShape = { + id: 'flow-value-slot-template-dialect-refused', + toMajor: 18, + surface: 'flows[].nodes[].config — a value slot (a get_record, update_record or delete_record filter operand among them)', + detect(stack, report) { + const flows = stack.flows; + if (!Array.isArray(flows)) return; + flows.forEach((flow, fi) => { + if (!isDict(flow) || !Array.isArray(flow.nodes)) return; + const flowName = typeof flow.name === 'string' ? flow.name : `flows[${fi}]`; + mapFlowNodeList(flow.nodes, `flows[${fi}].nodes`, (node, path) => { + if (typeof node.type !== 'string') return node; + for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config)) { + report({ + path: `${path}.config.${refusal.path}`, + from: JSON.stringify(refusal.source), + reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at ` + + `config.${refusal.path}. No lossless rewrite: the template resolved a token CEL reads ` + + 'differently, so the upgrade does not convert it. Left as stored, registerFlow refuses the flow: ' + + 'it is not registered at boot and never runs. ' + + refusal.message, + }); + } + return node; + }); + }); + }, +}; + +/** + * Every refused stored shape, in report order. A new entry is a reviewed edit: + * `refused-shapes.test.ts` pins each one. + */ +export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ + controlledByParentWithoutOneMaster, + viewDataApiProvider, + flowValueSlotTemplateToken, +]; + +/** + * Run every {@link REFUSED_STORED_SHAPES} detector over `stack` and hand each + * finding to `onTodo` as a {@link ConversionTodoNotice}, whose `conversionId` + * is the refusal's D3 entry id. Reads only. + */ +export function reportRefusedStoredShapes( + stack: Dict, + onTodo: (todo: ConversionTodoNotice) => void, +): void { + for (const refused of REFUSED_STORED_SHAPES) { + refused.detect(stack, (detail) => + onTodo({ + code: CONVERSION_TODO_CODE, + conversionId: refused.id, + surface: refused.surface, + from: detail.from, + path: detail.path, + reason: detail.reason, + message: + `[protocol] left ${refused.surface} at ${detail.path} as stored — protocol ${refused.toMajor} ` + + `refuses it (ADR-0087 D3 entry '${refused.id}') and no conversion rewrites it losslessly: ` + + detail.reason, + })); + } +} diff --git a/packages/spec/src/conversions/stored.ts b/packages/spec/src/conversions/stored.ts index 19164ab3fa9..1c948ff8d70 100644 --- a/packages/spec/src/conversions/stored.ts +++ b/packages/spec/src/conversions/stored.ts @@ -69,7 +69,10 @@ const STORED_ONLY_COLLECTIONS: Readonly> = { * * `onTodo` rides along unchanged: the stored-metadata migration pass passes it * to list the sites a conversion left as stored for want of a lossless rewrite - * (ADR-0087 D3), which emit no notice and would otherwise read as canonical. + * (ADR-0087 D3), which emit no notice and would otherwise read as canonical — + * and, with it, every shape protocol 18 refuses that no conversion covers + * (`refused-shapes.ts`: a `controlled_by_parent` object without exactly one + * required master, a view bound to the retired view-level API source). */ export type StoredConversionOptions = Omit; diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index acb6eca52df..b2b1ed45319 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -49,6 +49,12 @@ export const CONVERSION_CONFLICT_CODE = 'OS_METADATA_CONVERSION_CONFLICT' as con * reads the notices would count the site as already canonical. The TODO is * what lets an operator answer "did it convert my row" from the pass's own * output. + * + * The same code carries a shape NO conversion covers: a stored shape a + * protocol refuses with no lossless rewrite at all (`refused-shapes.ts`, + * #23269 / #23270 / #23271). Such a TODO names, in place of a conversion, the + * D3 semantic entry that refuses the shape — see + * {@link ConversionTodoNotice.conversionId}. */ export const CONVERSION_TODO_CODE = 'OS_METADATA_CONVERSION_TODO' as const; @@ -135,7 +141,12 @@ export interface ConversionTodoDetail { */ export interface ConversionTodoNotice { code: typeof CONVERSION_TODO_CODE; - /** The {@link MetadataConversion.id} whose surface the site is on. */ + /** + * The {@link MetadataConversion.id} whose surface the site is on — or, for a + * stored shape no conversion covers (a refused shape, see + * {@link CONVERSION_TODO_CODE}), the id of the D3 semantic entry that + * refuses it. + */ conversionId: string; /** Dotted surface the conversion governs. */ surface: string; diff --git a/packages/spec/src/data/controlled-by-parent-master.ts b/packages/spec/src/data/controlled-by-parent-master.ts new file mode 100644 index 00000000000..b3e9109ee63 --- /dev/null +++ b/packages/spec/src/data/controlled-by-parent-master.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0055 §1 · #23100, maintainer ruling B] The ONE predicate behind "a + * `controlled_by_parent` object derives its master from exactly one + * `master_detail` field declared `required: true`". + * + * Two readers, one judgement: + * + * - `ObjectSchema`'s refinement (`refuseControlledByParentWithoutOneMaster`, + * `object.zod.ts`) turns a verdict into the parse issue, with the message + * carried here; + * - the stored-row listing (`conversions/refused-shapes.ts`) turns the same + * verdict into the TODO the v18 upgrade prints for a stored row it refuses + * (#23270), with the shape that decides its fate. + * + * Kept in one module so the two cannot drift: an object the upgrade's preview + * lists is exactly an object the parse refuses, and the reverse. + * + * ⚠️ Internal to `packages/spec`. Not re-exported from `data/index.ts` or any + * other entry: publishing the predicate is its own card, so this module adds + * nothing to the package's public surface. + * + * Read on either the PARSED field map or a stored body: `required` is + * `z.boolean().default(false)`, so on the parsed map an omitted `required` is + * `false`, and on a stored body it is `undefined`. The predicate counts a + * master only where `required === true`, so the two give the same verdict. + */ + +/** + * The shape of a refused `controlled_by_parent` object — what decides its + * fate in the v18 upgrade: + * + * - `several-required-masters` — two or more `master_detail` fields declared + * `required: true`; choosing the master is the owner's call; + * - `several-masters` — two or more `master_detail` fields and none declared + * `required: true`; again a pick; + * - `master-not-required` — exactly one `master_detail`, not declared + * `required: true`. An OMITTED `required` is converted by the D2 entry + * `cbp-master-detail-required-defaulted` before this is read, so on a stored + * row that has been through the chain this is an explicit `required: false` + * (or a non-boolean the field parse refuses on its own); + * - `required-lookup-only` — no `master_detail`, one or more required `lookup` + * fields (the retired third tier); + * - `no-master` — no `master_detail` and no required `lookup`. + */ +export type ControlledByParentMasterShape = + | 'several-required-masters' + | 'several-masters' + | 'master-not-required' + | 'required-lookup-only' + | 'no-master'; + +/** Why an object fails the one-required-master rule — see {@link controlledByParentMasterRefusal}. */ +export interface ControlledByParentMasterRefusal { + /** Where the parse reports it: `fields` (more than one candidate) or `sharingModel` (none). */ + readonly path: 'fields' | 'sharingModel'; + readonly shape: ControlledByParentMasterShape; + /** The `master_detail` fields the object declares, in field order. */ + readonly masterDetails: readonly string[]; + /** The `lookup` fields declared `required: true`, in field order. */ + readonly requiredLookups: readonly string[]; + /** The parse issue's message, naming the object and the fix. */ + readonly message: string; +} + +/** + * Judge one object against ADR-0055 §1's one-required-master rule. Returns + * `undefined` when the rule does not apply (another sharing model, no field + * map) or the object satisfies it; otherwise the refusal. + */ +export function controlledByParentMasterRefusal( + objectName: unknown, + sharingModel: unknown, + fields: unknown, +): ControlledByParentMasterRefusal | undefined { + if (sharingModel !== 'controlled_by_parent') return undefined; + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return undefined; + const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; + const entries = Object.entries(fields as Record); + const masterDetails = entries.filter(([, f]) => f?.type === 'master_detail').map(([key]) => key); + const requiredLookups = entries + .filter(([, f]) => f?.type === 'lookup' && f.required === true) + .map(([key]) => key); + const masters = entries + .filter(([, f]) => f?.type === 'master_detail' && f.required === true) + .map(([key]) => key); + if (masters.length === 1) return undefined; + if (masters.length > 1) { + return { + path: 'fields', + shape: 'several-required-masters', + masterDetails, + requiredLookups, + message: + `Object '${name}' declares sharingModel 'controlled_by_parent' with ${masters.length} required ` + + `\`master_detail\` fields (${masters.map((m) => `\`${m}\``).join(', ')}). ADR-0055 §1: a ` + + 'controlled-by-parent object derives its master from exactly one required `master_detail` field, so ' + + 'with two the declaration does not say which master governs its rows, and the platform derives none ' + + '(every read is denied, every write refused). Keep `master_detail` on the one master, and declare ' + + 'every other parent as a `lookup` field.', + }; + } + const found = [ + ...masterDetails.map((m) => `\`${m}\` is a \`master_detail\` without \`required: true\` — add it`), + ...requiredLookups.map( + (l) => `\`${l}\` is a required \`lookup\`, and a lookup derives no access — declare it ` + + "`type: 'master_detail'` (a detail is deleted with its master; declare `deleteBehavior: 'restrict'` " + + 'to refuse deleting a master that still has rows instead)', + ), + ]; + const shape: ControlledByParentMasterShape = + masterDetails.length > 1 + ? 'several-masters' + : masterDetails.length === 1 + ? 'master-not-required' + : requiredLookups.length > 0 + ? 'required-lookup-only' + : 'no-master'; + return { + path: 'sharingModel', + shape, + masterDetails, + requiredLookups, + message: + `Object '${name}' declares sharingModel 'controlled_by_parent' but no required \`master_detail\` field. ` + + 'ADR-0055 §1: since @objectstack/spec 18, a controlled-by-parent object derives its master from exactly ' + + 'one `master_detail` field declared `required: true`, and from nothing else, so as declared the ' + + 'platform derives no master ' + + '(every read is denied, every write refused). ' + + (found.length > 0 ? `${found.join('; ')}. ` : '') + + 'If the rows must outlive their parent, this object is not a detail: give it its own sharingModel ' + + "('private', 'public_read' or 'public_read_write').", + }; +} diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 6347359316f..3fc69b76122 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -22,6 +22,10 @@ import { FIELD_GROUP_KEY_PATTERN } from './field-group-layout'; // `password` exemption rather than restating it. No runtime cycle: that module's // only imports are type-only. import { isMaskedOnReadFieldType } from './masked-field-types'; +// The one-required-master predicate, shared with the v18 stored-row listing +// (`conversions/refused-shapes.ts`) so the parse and the upgrade's preview +// refuse one set. Internal: not re-exported by `data/index.ts`. +import { controlledByParentMasterRefusal } from './controlled-by-parent-master'; export const ApiMethod = z.enum([ 'get', 'list', // Read 'create', 'update', 'delete', // Write @@ -1973,6 +1977,11 @@ function refuseAttachedBlockConflicts(attachedOnRead: unknown, fields: unknown, * rather leave `controlled_by_parent` is the author's decision. * The conversion is retired from the authoring load path, so a live author * still meets this refusal. + * + * The predicate and the message live in `controlled-by-parent-master.ts` + * ({@link controlledByParentMasterRefusal}), which the v18 upgrade's stored-row + * listing reads too: `os migrate meta --stored` lists each stored object this + * refinement refuses, by object, with the fate of its shape (#23270). */ function refuseControlledByParentWithoutOneMaster( objectName: unknown, @@ -1980,53 +1989,9 @@ function refuseControlledByParentWithoutOneMaster( fields: unknown, ctx: z.RefinementCtx, ): void { - if (sharingModel !== 'controlled_by_parent') return; - if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return; - const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; - const entries = Object.entries(fields as Record); - const masters = entries - .filter(([, f]) => f?.type === 'master_detail' && f.required === true) - .map(([key]) => key); - if (masters.length === 1) return; - if (masters.length > 1) { - ctx.addIssue({ - code: 'custom', - path: ['fields'], - message: - `Object '${name}' declares sharingModel 'controlled_by_parent' with ${masters.length} required ` - + `\`master_detail\` fields (${masters.map((m) => `\`${m}\``).join(', ')}). ADR-0055 §1: a ` - + 'controlled-by-parent object derives its master from exactly one required `master_detail` field, so ' - + 'with two the declaration does not say which master governs its rows, and the platform derives none ' - + '(every read is denied, every write refused). Keep `master_detail` on the one master, and declare ' - + 'every other parent as a `lookup` field.', - }); - return; - } - const optionalMasters = entries.filter(([, f]) => f?.type === 'master_detail').map(([key]) => key); - const requiredLookups = entries - .filter(([, f]) => f?.type === 'lookup' && f.required === true) - .map(([key]) => key); - const found = [ - ...optionalMasters.map((m) => `\`${m}\` is a \`master_detail\` without \`required: true\` — add it`), - ...requiredLookups.map( - (l) => `\`${l}\` is a required \`lookup\`, and a lookup derives no access — declare it ` - + "`type: 'master_detail'` (a detail is deleted with its master; declare `deleteBehavior: 'restrict'` " - + 'to refuse deleting a master that still has rows instead)', - ), - ]; - ctx.addIssue({ - code: 'custom', - path: ['sharingModel'], - message: - `Object '${name}' declares sharingModel 'controlled_by_parent' but no required \`master_detail\` field. ` - + 'ADR-0055 §1: since @objectstack/spec 18, a controlled-by-parent object derives its master from exactly ' - + 'one `master_detail` field declared `required: true`, and from nothing else, so as declared the ' - + 'platform derives no master ' - + '(every read is denied, every write refused). ' - + (found.length > 0 ? `${found.join('; ')}. ` : '') - + 'If the rows must outlive their parent, this object is not a detail: give it its own sharingModel ' - + "('private', 'public_read' or 'public_read_write').", - }); + const refusal = controlledByParentMasterRefusal(objectName, sharingModel, fields); + if (refusal === undefined) return; + ctx.addIssue({ code: 'custom', path: [refusal.path], message: refusal.message }); } // ⚠️ ORDER IS LOAD-BEARING (#5593). This map used to live ~700 lines BELOW From 51f5823bded4b18653e78a72d1f69bcc38cd214f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:03:32 +0000 Subject: [PATCH 2/6] wip(service-automation,metadata-protocol): a stored flow's TODOs reach the stored preview Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .../src/protocol.stored-migration.test.ts | 140 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 14 +- .../metadata-protocol/src/stored-migration.ts | 16 ++ .../src/canonicalize-stored-flow.test.ts | 46 ++++++ .../services/service-automation/src/engine.ts | 18 ++- 5 files changed, 232 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index ebad1f730bd..890f61e88da 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -26,6 +26,7 @@ import { describe, expect, it } from 'vitest'; // of this package's (file, verb) pairs sat in the gate's DEBT ledger until // #5619 sank the two predicates into a package both sides already depend on. import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { applyConversionsToFlow, type ConversionTodoNotice } from '@objectstack/spec'; import { applyMetaMigrations } from '@objectstack/spec/migrations'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { @@ -1083,3 +1084,142 @@ describe('migrateStoredMetadata — the decision review list: stored rows take f expect(formatStoredMigrationReport(report).join('\n')).not.toMatch(/FIRST matching branch/); }); }); + +describe('migrateStoredMetadata — the v18 stored shapes refused with no lossless rewrite are LISTED, never rewritten (#23269, #23270, #23271)', () => { + // Each of these rows reads as on protocol to every conversion — nothing + // rewrites it — while the parse or the boot refuses it after the upgrade. + // Before the refused-shape list, the preview counted each one `canonical`. + // The retired view-level provider, spelled through a constant (a tree-scoped + // absence pin in @objectstack/spec reads `provider: 'api'` literals). + const API = 'api' as const; + const cbpObject = (name: string, fields: Record) => ({ + type: 'object', + name, + metadata: { name, label: name, sharingModel: 'controlled_by_parent', fields: { title: { type: 'text', label: 'Title' }, ...fields } }, + }); + const md = (reference: string, extra: Record = {}) => ({ type: 'master_detail', label: reference, reference, ...extra }); + const crudFlow = (name: string, operand: unknown) => ({ + type: 'flow', + name, + metadata: { + name, + label: name, + type: 'autolaunched', + status: 'active', + nodes: [{ id: 'get', type: 'get_record', label: 'Get', config: { objectName: 'lead', filter: { id: operand } } }], + edges: [], + }, + }); + /** Stands in for `AutomationEngine.canonicalizeStoredFlow`: the same chain, the same TODO sink. */ + const canonicalizeFlow = (_name: string, body: unknown) => { + const todos: ConversionTodoNotice[] = []; + const storable = applyConversionsToFlow(body, { includeRetired: true, onTodo: (t) => todos.push(t) }); + return { storable, notices: [], conflicts: [], todos }; + }; + + it('#23270 — each refused controlled_by_parent shape is listed by object; a conforming object is counted canonical', async () => { + const { engine, tables } = makeStubEngine([ + cbpObject('crm_two', { quote: md('crm_quote', { required: true }), order: md('crm_order', { required: true }) }), + cbpObject('crm_none', {}), + cbpObject('crm_lookup', { quote: { type: 'lookup', label: 'Quote', reference: 'crm_quote', required: true } }), + cbpObject('crm_false', { quote: md('crm_quote', { required: false }) }), + cbpObject('crm_ok', { quote: md('crm_quote', { required: true }) }), + ]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.canonical).toBe(1); + expect(report.rows.map((r) => [r.name, r.outcome, r.todos.map((t) => [t.conversionId, t.path])])).toEqual([ + ['crm_two', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].fields']]], + ['crm_none', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]], + ['crm_lookup', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]], + ['crm_false', 'skipped', [['cbp-master-relation-tiers-retired', 'objects[0].sharingModel']]], + ]); + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toContain('object/crm_two [env-wide]'); + expect(text).toContain('TODO cbp-master-relation-tiers-retired:'); + expect(text).toMatch(/which of them is the master is the owner's choice: the upgrade never picks one/); + expect(text).toMatch(/☐ TODO: 4 site\(s\) in 4 row\(s\) are left as stored/); + // Listed, never written — on --apply too — and no verdict flips over it. + expect(storedMigrationClean(report)).toBe(true); + const applied = await protocol.migrateStoredMetadata({ apply: true }); + expect(applied.rewritten).toBe(0); + expect(JSON.stringify(metaRows(tables))).toBe(before); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('#23271 — a view bound to the retired API source is listed by view, without its headers; another provider is canonical', async () => { + const apiView = { + type: 'view', + name: 'partner_cases', + metadata: { + object: 'crm_case', + list: { + type: 'grid', + columns: ['name'], + data: { provider: API, read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } } }, + }, + }, + }; + const objectView = { + type: 'view', + name: 'open_cases', + metadata: { object: 'crm_case', list: { type: 'grid', columns: ['name'], data: { provider: 'object', object: 'crm_case' } } }, + }; + const { engine } = makeStubEngine([apiView, objectView]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.canonical).toBe(1); + expect(report.rows).toHaveLength(1); + const row = report.rows[0]!; + expect(row).toMatchObject({ type: 'view', name: 'partner_cases', outcome: 'skipped' }); + expect(row.todos.map((t) => [t.conversionId, t.path])).toEqual([['view-data-api-provider-retired', 'views[0].list.data']]); + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toContain('view/partner_cases [env-wide]'); + expect(text).toMatch(/No mechanical move: the replacement is a `dataSource` binding on a page component/); + expect(text).not.toContain('s3cr3t'); + }); + + it('#23269 — a flow whose CRUD filter operand is a {…} token is listed by flow and node; the CEL form is canonical', async () => { + const { engine, tables } = makeStubEngine([ + crudFlow('fd_refused_filter', '{record.id}'), + crudFlow('fd_cel_filter', { dialect: 'cel', source: 'record.id' }), + ]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ canonicalizeFlow }); + + expect(report.canonical).toBe(1); + expect(report.rows).toHaveLength(1); + const row = report.rows[0]!; + expect(row).toMatchObject({ type: 'flow', name: 'fd_refused_filter', outcome: 'skipped', notices: [] }); + expect(row.todos.map((t) => [t.conversionId, t.path, t.from])).toEqual([ + ['flow-value-slot-template-dialect-refused', 'flows[0].nodes[0].config.filter.id', JSON.stringify('{record.id}')], + ]); + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toContain('flow/fd_refused_filter [env-wide]'); + expect(text).toContain("Flow `fd_refused_filter`, node 'get' (get_record), get_record filter operand at config.filter.id"); + expect(text).toContain('registerFlow refuses the flow: it is not registered at boot and never runs'); + // Listed, never written. + const applied = await protocol.migrateStoredMetadata({ apply: true, canonicalizeFlow }); + expect(applied.rewritten).toBe(0); + expect(JSON.stringify(metaRows(tables))).toBe(before); + }); + + it('a flow canonicalizer that reports no `todos` still fits the contract, and reads as none', async () => { + const { engine } = makeStubEngine([crudFlow('fd_refused_filter', '{record.id}')]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ + canonicalizeFlow: (_n, body) => ({ storable: body, notices: [], conflicts: [] }), + }); + + expect(report.canonical).toBe(1); + expect(report.rows).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index d2367e10bc7..60b17a8e76d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22325,6 +22325,12 @@ export class ObjectStackProtocolImplementation implements * a pre-protocol shape it cannot rewrite without changing what it means * — a page filter carrying `$or`, which a flat rule list cannot spell — * leaves the site byte-identical and reports a TODO instead of a notice. + * The same list carries the shapes protocol 18 refuses that no + * conversion covers (#23269, #23270, #23271: a `controlled_by_parent` + * object without exactly one required `master_detail`, a view bound to + * the retired view-level API source, a flow value slot carrying a `{…}` + * token), each TODO naming the D3 entry that refuses it. A flow row's + * TODOs come back from the flow canonicalizer. * Every such site is listed under its row (`rows[].todos`) with the * block and the reason, whatever the row's outcome; a row whose ONLY * finding is TODOs has nothing to persist and is reported `skipped`, @@ -22611,8 +22617,14 @@ export class ObjectStackProtocolImplementation implements // still changing the body. Both passes are copy-on-write, so // identity is the precise test there: `storable === body` exactly // when nothing was rewritten at all. + // + // [#23269] A flow's TODOs come back from the canonicalizer, which + // runs the same chain with the same sink: until they did, a stored + // flow carrying a `{…}` token at a value slot — a CRUD filter + // operand among them — parsed, rewrote nothing and was counted + // `canonical`, while `registerFlow` refused it at boot. const { item, notices, todos } = flowResult - ? { item: flowResult.storable, notices: flowResult.notices, todos: [] as ConversionTodoNotice[] } + ? { item: flowResult.storable, notices: flowResult.notices, todos: flowResult.todos ?? [] } : this.convertStoredItemDetailed(singular, body); const changed = flowResult ? item !== body : notices.length > 0; // [#17321] A site the chain left as stored — no lossless rewrite diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts index 38e8687df66..4ff9daa3554 100644 --- a/packages/metadata-protocol/src/stored-migration.ts +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -58,6 +58,22 @@ export interface StoredFlowCanonicalization { * clobber that owner, and skipping quietly would hide it. */ conflicts: Array<{ conversionId: string; token: string; path: string; message: string }>; + /** + * Sites the chain left as stored because no lossless rewrite exists (#23269) + * — above all a value slot carrying a `{…}` template token, which + * `registerFlow` refuses although the parse accepts the flow. Listed under + * the row exactly as a non-flow row's TODOs are ({@link StoredMigrationTodo}). + * Optional, so a caller-supplied canonicalizer that reports none still fits; + * absent reads as none. + */ + todos?: Array<{ + conversionId: string; + surface: string; + from: string; + path: string; + reason: string; + message: string; + }>; } /** What the pass did with (or would do with) one `sys_metadata` row. */ diff --git a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts index 86ac0d2ac24..74ae232508c 100644 --- a/packages/services/service-automation/src/canonicalize-stored-flow.test.ts +++ b/packages/services/service-automation/src/canonicalize-stored-flow.test.ts @@ -175,3 +175,49 @@ describe('registerFlow still behaves identically (#4454 refactor)', () => { expect(returned.version).toBe(1); }); }); + +describe('canonicalizeStoredFlow — a site registerFlow refuses comes back as a TODO (#23269)', () => { + // A stored v17 flow: a `get_record` filter operand still in the retired + // single-brace dialect. `FlowSchema` accepts it and no conversion rewrites + // it (no lossless one exists), so without the TODO the stored pass read + // this flow as on protocol while `registerFlow` refused it at boot. + const storedFlow = (operand: unknown) => ({ + name: 'fd_refused_filter', + label: 'Refused Filter', + type: 'autolaunched', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'get', type: 'get_record', label: 'Get', config: { objectName: 'lead', filter: { id: operand } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'get' }, { id: 'e2', source: 'get', target: 'end' }], + }); + + it('a braced operand parses, is not rewritten, and is reported by node — and registerFlow refuses the same site', () => { + const engine = new AutomationEngine(silentLogger); + const flow = storedFlow('{record.id}'); + const { storable, notices, conflicts, todos } = engine.canonicalizeStoredFlow('fd_refused_filter', flow); + + expect(notices).toEqual([]); + expect(conflicts).toEqual([]); + expect((storable as any).nodes[1].config.filter).toEqual({ id: '{record.id}' }); + expect(todos.map((t) => [t.conversionId, t.path])).toEqual([ + ['flow-value-slot-template-dialect-refused', 'flows[0].nodes[1].config.filter.id'], + ]); + expect(todos[0]!.reason).toContain("node 'get' (get_record), get_record filter operand at config.filter.id"); + + // The listing and the boot refuse one site. + expect(() => engine.registerFlow('fd_refused_filter', flow)) + .toThrow(/node 'get' \(get_record\) get_record filter operand at config\.filter\.id/); + }); + + it('the same flow in the CEL form reports no TODO and registers', async () => { + const engine = new AutomationEngine(silentLogger); + const flow = storedFlow({ dialect: 'cel', source: 'record.id' }); + + expect(engine.canonicalizeStoredFlow('fd_refused_filter', flow).todos).toEqual([]); + engine.registerFlow('fd_refused_filter', flow); + expect(await engine.getFlow('fd_refused_filter')).not.toBeNull(); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 47eb2c9bbe2..a597cd20f6e 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -65,7 +65,7 @@ import { builtinNodeConfigKeysJudged } from '@objectstack/spec/automation'; // hand-written `trim()` here is exactly the drift the #15662 campaign built the // shared refusal to prevent. See `checkStructuralCondition` in `registerFlow`. import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec'; -import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice } from '@objectstack/spec'; +import { applyConversionsToFlow, type ConversionNotice, type ConversionConflictNotice, type ConversionTodoNotice } from '@objectstack/spec'; // [ADR-0126 §7.3, #20761] "Does a code package ship this flow?" is NOT read // off a definition here. The subflow guards, the arming gate and the // activation door ask {@link AutomationEngine.packagedFlowOwner} — the loader's @@ -4352,12 +4352,21 @@ export class AutomationEngine implements IAutomationService { * a flow carrying an unrecognized key is a hard error here rather than a * silent drop; a caller migrating stored rows reports that row as failed * instead of persisting a guess. + * + * `todos` (#23269) are the sites the chain leaves as stored because no + * lossless rewrite exists — above all a value slot carrying a `{…}` + * template token, a CRUD filter operand among them, which + * {@link registerFlow} goes on to refuse. The parse accepts such a flow, so + * without them the stored pass would read a flow the boot refuses as on + * protocol. They change nothing in `parsed` or `storable`; `registerFlow` + * ignores them, since its own refusal names the same sites. */ canonicalizeStoredFlow(name: string, definition: unknown): { parsed: FlowParsed; storable: unknown; notices: ConversionNotice[]; conflicts: ConversionConflictNotice[]; + todos: ConversionTodoNotice[]; } { // ADR-0087 D2 — the runtime load seam. A stored flow authored against an // old shape (a `webhook`/`http_request` callout node, a `delete_record` @@ -4392,6 +4401,7 @@ export class AutomationEngine implements IAutomationService { ]); const notices: ConversionNotice[] = []; const conflicts: ConversionConflictNotice[] = []; + const todos: ConversionTodoNotice[] = []; const converted = applyConversionsToFlow(definition, { reservedNodeTypes, includeRetired: true, @@ -4404,6 +4414,11 @@ export class AutomationEngine implements IAutomationService { conflicts.push(c); this.logger.warn(`[flow '${name}'] ${c.code}: ${c.message}`); }, + // Collected, not logged: a site listed here is one `registerFlow` + // refuses below with its own located message. + onTodo: (t) => { + todos.push(t); + }, }); // #4347 / #4415 — one call, canonical at every depth. `FlowNodeSchema` // parses its own ADR-0031 regions (`FlowNodeSchema.transform` → @@ -4428,6 +4443,7 @@ export class AutomationEngine implements IAutomationService { storable: graftConditionEnvelopes(converted, parsed), notices, conflicts, + todos, }; } From 2f8e85c2ecc3dda31eb5110fb19528a6edd21df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 15:14:23 +0000 Subject: [PATCH 3/6] wip(spec,docs): the three D3 entries and the stored-pass docs name the listing; changeset Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .changeset/23269-v18-stored-row-fates.md | 19 +++++++++++++++++++ content/docs/deployment/cli.mdx | 1 + .../18.cbp-master-relation-tiers-retired.ts | 5 ++++- ...low-value-slot-template-dialect-refused.ts | 4 +++- .../18.view-data-api-provider-retired.ts | 4 +++- 5 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .changeset/23269-v18-stored-row-fates.md diff --git a/.changeset/23269-v18-stored-row-fates.md b/.changeset/23269-v18-stored-row-fates.md new file mode 100644 index 00000000000..16315c691ad --- /dev/null +++ b/.changeset/23269-v18-stored-row-fates.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +'@objectstack/service-automation': patch +'@objectstack/metadata-protocol': patch +--- + +fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. + +Clause-②: no + +Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. + +**#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed. + +**#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. + +**#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. + +How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape. `AutomationEngine.canonicalizeStoredFlow` now returns `todos` beside `notices` and `conflicts`, and the stored pass lists a flow row's TODOs the way it lists any other row's. The authoring funnel and the read seams pass no sink, so nothing changes for them. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index ac0953ae6f5..96d87829620 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1429,6 +1429,7 @@ can produce, because both supply a live one. | Rows that still fail the current schema after conversion | That is a genuine contract violation, not chain-owned history. The write path's rejection is correct; fix the row in Studio | | A flow whose rename the conflict guard refused | The old node-type token is a live name something else owns here. Rewriting would clobber that owner, so the row fails loudly naming the token — never a silent skip | | A site the conversion chain leaves as stored because no lossless rewrite exists — above all a page filter carrying `$and` / `$or` / `$not` | Flattening a combinator changes which rows the page selects, so it is never done. Each site is printed as a `TODO` line under its row — path, block, and what blocks the rewrite — whatever the row's outcome; a row with nothing but TODOs is reported `skipped`. It does not fail the run, since no run of this pass can clear it: rewrite each site by hand | +| A stored shape protocol 18 refuses and no conversion rewrites — a `controlled_by_parent` object without exactly one required `master_detail` (two masters, none, a required `lookup` only, an explicit `required: false`), a view bound to the retired view-level API data source, a flow value slot carrying a `{…}` token (a `get_record` / `update_record` / `delete_record` filter operand among them) | Each would need a choice only its owner can make — which master, which page, what an absent value should do — so it is never rewritten. Each site is printed as a `TODO` line under its row, naming the step-18 entry that refuses it, the object, view or flow node, and the owner's choices; a view's request headers are withheld. Until it is rewritten by hand, the parse or the boot refuses the row: an object registers but derives no master, a view registers under `[metadata_spec_invalid]`, and a flow is not registered | **One thing it lists and never writes: decision nodes that changed meaning at protocol 18.** A stored `decision` with no `config.conditions`, no `mode` and two diff --git a/packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts b/packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts index c4bf113ffb8..6758179f226 100644 --- a/packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.cbp-master-relation-tiers-retired.ts @@ -56,5 +56,8 @@ export const entry: SemanticMigration = { + 'line for the object, and a member who can read its master reads its rows. A stored row whose one ' + '`master_detail` omitted `required` is served with `required: true` (`cbp-master-detail-required-defaulted`); ' + 'a stored row in any other retired shape still registers (with its `[metadata_spec_invalid]` ' - + 'diagnostic) and is denied until it is corrected and saved.', + + 'diagnostic) and is denied until it is corrected and saved. `os migrate meta --stored` lists each such ' + + 'row as a TODO under this entry\'s id, before the upgrade and after it, naming the object, its shape (two ' + + 'masters, none, a required `lookup` only, an explicit `required: false`) and the owner\'s choice, and ' + + 'rewrites none of them.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts index c82c55bf3aa..46c7d6885e7 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-value-slot-template-dialect-refused.ts @@ -190,5 +190,7 @@ export const entry: SemanticMigration = { + 'variable named like one through an envelope. Re-run ' + 'the flow paths that write those fields, serve those screens, iterate those lists, send those ' + 'notifications, make those http calls and run those queries, and compare the stored and served values, the iterations, the ' - + 'audiences, the request payloads and the rows matched with the ones the template produced.', + + 'audiences, the request payloads and the rows matched with the ones the template produced. ' + + '`os migrate meta --stored` lists each stored flow carrying a refused value as a TODO under this ' + + 'entry\'s id, before the upgrade and after it, by flow, node and path, and rewrites none of them.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts index a2dc3958550..a39fac8e70d 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts @@ -34,5 +34,7 @@ export const entry: SemanticMigration = { + "`sys_metadata` view row carries `data.provider: 'api'`; the parse refuses one with the prescription. " + "Every endpoint such a view read is either a page component's API binding on a same-origin path " + 'with no credential key, or dropped on purpose; and every converted view binds its object or static ' - + 'rows and loads.', + + 'rows and loads. `os migrate meta --stored` lists each stored view still bound to the API source as a ' + + 'TODO under this entry\'s id, before the upgrade and after it, by row and position, showing the ' + + 'request URL and method and withholding every other request key, and rewrites none of them.', }; From b111eead9f2bd0ffbcc3931a7fb54ddc4a70d4ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:16:17 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(metadata-protocol):=20a=20TODO-only=20r?= =?UTF-8?q?ow's=20reason=20no=20longer=20claims=20the=20row=20keeps=20load?= =?UTF-8?q?ing=20=E2=80=94=20a=20refused=20flow=20does=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 60b17a8e76d..73e068b2030 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22657,8 +22657,8 @@ export class ObjectStackProtocolImplementation implements outcome: 'skipped', reason: `the conversion chain rewrites nothing here: it left ${flattenedTodos.length} ` + 'site(s) of this row as stored, because no lossless rewrite exists for them — ' - + 'each TODO below names the site and why. The row keeps loading unchanged; ' - + 'rewrite each site by hand.', + + 'each TODO below names the site and why. The row is read as stored, unchanged, ' + + 'and each TODO says what that leaves it doing; rewrite each site by hand.', }); continue; } From f67be95f5d4d463e9d02f0cd1f40ee6655d3242d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:52:59 +0000 Subject: [PATCH 5/6] refactor(spec): the refused-shape TODO is stamped where every conversion TODO is check:dispatcher-error-vocabulary classifies OS_METADATA_CONVERSION_TODO per (code, file); the detector list now hands back (refused, detail) and applyConversions builds the notice. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- packages/spec/src/conversions/apply.ts | 16 ++++++++- .../spec/src/conversions/refused-shapes.ts | 34 ++++++------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index 128ee27f419..e0d9473cfca 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -210,7 +210,21 @@ export function applyConversions( // [#23269, #23270, #23271] What protocol 18 refuses and nothing converts, // judged on the converted stack and listed through the same sink: a row the // chain leaves refused must not read as already on protocol. - if (onTodo) reportRefusedStoredShapes(current, onTodo); + if (onTodo) { + reportRefusedStoredShapes(current, (refused, detail) => + onTodo({ + code: CONVERSION_TODO_CODE, + conversionId: refused.id, + surface: refused.surface, + from: detail.from, + path: detail.path, + reason: detail.reason, + message: + `[protocol] left ${refused.surface} at ${detail.path} as stored — protocol ${refused.toMajor} ` + + `refuses it (ADR-0087 D3 entry '${refused.id}') and no conversion rewrites it losslessly: ` + + detail.reason, + })); + } return current; } diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index d1dd43920aa..9c396b044e3 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -23,8 +23,8 @@ * `onTodo` sink. Two callers pass one: the stored-metadata migration pass * (through `applyConversionsToStoredItem`), and the automation engine's * `canonicalizeStoredFlow`, which that pass calls for a flow row. Each finding - * reaches the sink as a {@link ConversionTodoNotice}, under its row, exactly - * like a site a conversion left as stored. The authoring funnel and the read + * reaches the sink as a `ConversionTodoNotice`, under its row, exactly like a + * site a conversion left as stored. The authoring funnel and the read * seams pass no sink, so for them nothing here runs. * * ## What it never does @@ -50,11 +50,7 @@ import { type ControlledByParentMasterRefusal, } from '../data/controlled-by-parent-master.js'; import { ViewDataSchema } from '../ui/view.zod.js'; -import { - CONVERSION_TODO_CODE, - type ConversionTodoDetail, - type ConversionTodoNotice, -} from './types.js'; +import type { ConversionTodoDetail } from './types.js'; import { mapCollection, mapFlowNodeList, mapViewPayloads } from './walk.js'; type Dict = Record; @@ -275,26 +271,18 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ /** * Run every {@link REFUSED_STORED_SHAPES} detector over `stack` and hand each - * finding to `onTodo` as a {@link ConversionTodoNotice}, whose `conversionId` - * is the refusal's D3 entry id. Reads only. + * finding to `report` with the refused shape it belongs to. Reads only. + * + * The notice itself — its `code`, its `conversionId` (the refusal's D3 entry + * id) and its message — is built by the one caller, `applyConversions`, beside + * the conversion TODOs it already builds, so the TODO code is + * stamped at one site. */ export function reportRefusedStoredShapes( stack: Dict, - onTodo: (todo: ConversionTodoNotice) => void, + report: (refused: RefusedStoredShape, detail: ConversionTodoDetail) => void, ): void { for (const refused of REFUSED_STORED_SHAPES) { - refused.detect(stack, (detail) => - onTodo({ - code: CONVERSION_TODO_CODE, - conversionId: refused.id, - surface: refused.surface, - from: detail.from, - path: detail.path, - reason: detail.reason, - message: - `[protocol] left ${refused.surface} at ${detail.path} as stored — protocol ${refused.toMajor} ` - + `refuses it (ADR-0087 D3 entry '${refused.id}') and no conversion rewrites it losslessly: ` - + detail.reason, - })); + refused.detect(stack, (detail) => report(refused, detail)); } } From 086d6f1adca2add2f9d36ca75ec33b85c6c72786 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:50:14 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(spec)!:=20IAutomationService.canonicali?= =?UTF-8?q?zeStoredFlow=20declares=20the=20todos=20the=20stored=20pass=20r?= =?UTF-8?q?eads;=20the=20changeset=20is=20minor,=20Clause-=E2=91=A1=20yes?= =?UTF-8?q?=20(widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract-tier review on the family PR found the consumer reading a member the published contract never promised. Declared as the optional sibling of notices and conflicts; StoredFlowCanonicalization restates it. Claude-Session: https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H Co-authored-by: Claude --- .changeset/23269-v18-stored-row-fates.md | 16 +++++++++++----- .../metadata-protocol/src/stored-migration.ts | 5 +++-- .../spec/src/contracts/automation-service.ts | 13 +++++++++++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.changeset/23269-v18-stored-row-fates.md b/.changeset/23269-v18-stored-row-fates.md index 16315c691ad..1e7069a6844 100644 --- a/.changeset/23269-v18-stored-row-fates.md +++ b/.changeset/23269-v18-stored-row-fates.md @@ -1,12 +1,12 @@ --- -'@objectstack/spec': patch -'@objectstack/service-automation': patch -'@objectstack/metadata-protocol': patch +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/metadata-protocol': minor --- fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. -Clause-②: no +Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before) Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. @@ -16,4 +16,10 @@ Three step-18 retirements refuse a stored shape that has no lossless rewrite. Up **#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. -How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape. `AutomationEngine.canonicalizeStoredFlow` now returns `todos` beside `notices` and `conflicts`, and the stored pass lists a flow row's TODOs the way it lists any other row's. The authoring funnel and the read seams pass no sink, so nothing changes for them. +How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages: + +- `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms. +- `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites. +- `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's. + +The authoring funnel and the read seams pass no sink, so nothing changes for them. diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts index 4ff9daa3554..50b13d395af 100644 --- a/packages/metadata-protocol/src/stored-migration.ts +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -63,8 +63,9 @@ export interface StoredFlowCanonicalization { * — above all a value slot carrying a `{…}` template token, which * `registerFlow` refuses although the parse accepts the flow. Listed under * the row exactly as a non-flow row's TODOs are ({@link StoredMigrationTodo}). - * Optional, so a caller-supplied canonicalizer that reports none still fits; - * absent reads as none. + * It restates the contract's own optional member, + * `IAutomationService.canonicalizeStoredFlow(...).todos?` (`@objectstack/spec`): + * a canonicalizer that reports none still conforms, and absent reads as none. */ todos?: Array<{ conversionId: string; diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 5284e4a9e82..b42795b3ba0 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -17,7 +17,7 @@ import type { FlowParsed } from '../automation/flow.zod'; import type { ExecutionLog, ExecutionStatus, FlowRunSummary } from '../automation/execution.zod'; import type { ActionDescriptor } from '../automation/node-executor.zod'; import type { ConnectorDescriptor } from '../integration/connector-descriptor'; -import type { ConversionNotice, ConversionConflictNotice } from '../conversions/types'; +import type { ConversionNotice, ConversionConflictNotice, ConversionTodoNotice } from '../conversions/types'; import type { ExecutionContext } from '../kernel/execution-context.zod'; /** @@ -801,7 +801,8 @@ export interface IAutomationService { * `storable` (persistence shape — conversions plus the schema's * `condition` envelopes, deliberately WITHOUT schema defaults, so a * written-back row is not frozen on today's default values), plus the - * conversions applied and any rewrite the guard refused. + * conversions applied, any rewrite the guard refused, and (`todos`) the + * sites left as stored for want of a lossless rewrite. * @throws when the definition cannot be canonicalized at all (a strict-schema * violation, a malformed control-flow region) — such a flow cannot be * registered either, so a caller reports it rather than persisting a guess. @@ -811,6 +812,14 @@ export interface IAutomationService { storable: unknown; notices: ConversionNotice[]; conflicts: ConversionConflictNotice[]; + /** + * The sites the conversion chain left as stored because no lossless + * rewrite exists — a value slot carrying a `{…}` token among them, which + * {@link registerFlow} refuses although the parse accepts it — so + * `os migrate meta --stored` lists a flow the boot refuses (#23269); + * absent reads as none. + */ + todos?: ConversionTodoNotice[]; }; /**