From 313f54fada6d3fe3dcafb0965c7ebe0d294fd8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:59:54 +0000 Subject: [PATCH 01/12] =?UTF-8?q?feat(spec,metadata-protocol):=20conversio?= =?UTF-8?q?n=20TODO=20channel=20=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/metadata-protocol/src/index.ts | 1 + packages/metadata-protocol/src/protocol.ts | 72 ++++- .../metadata-protocol/src/stored-migration.ts | 73 +++++- .../src/dispatcher-error-vocabulary.ts | 13 + packages/spec/src/conversions/apply.ts | 31 ++- packages/spec/src/conversions/index.ts | 3 + packages/spec/src/conversions/registry.ts | 248 ++++++++++++++---- packages/spec/src/conversions/stored.ts | 4 + packages/spec/src/conversions/types.ts | 65 ++++- 9 files changed, 448 insertions(+), 62 deletions(-) diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 9963d5e511a..294b1be9ad5 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -164,6 +164,7 @@ export type { StoredMigrationOutcome, StoredMigrationReport, StoredMigrationRow, + StoredMigrationTodo, } from './stored-migration.js'; export { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a9a77c8f6dc..46fff791a65 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -106,7 +106,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. import type { IPubSub } from '@objectstack/spec/contracts'; -import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; +import { applyConversionsToStoredItem, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel } from '@objectstack/spec/ui'; // [#11350] Emitted-specifier pin. This module's inferred public declarations // structurally mention `FormFieldInput` (FormView `sections[].fields`), and @@ -174,6 +174,7 @@ import type { StoredMigrationNotice, StoredMigrationReport, StoredMigrationRow, + StoredMigrationTodo, } from './stored-migration.js'; /** @@ -4762,22 +4763,32 @@ export class ObjectStackProtocolImplementation implements * instead would be weaker — the pass is copy-on-write, so an untouched * branch is shared and a re-serialized identical body can still differ in * key order. + * + * [#17321] The TODOs ride along for the same caller. A conversion that + * recognises a pre-protocol shape it cannot rewrite losslessly leaves the + * site as stored and emits NO notice for it, so "no notices" alone means + * "nothing to persist", never "on protocol": {@link migrateStoredMetadata} + * reads `todos` to tell the two apart. They change nothing in `item`. */ private convertStoredItemDetailed( type: string, data: unknown, onNotice?: (notice: ConversionNotice) => void, - ): { item: unknown; notices: ConversionNotice[] } { + ): { item: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } { const singular = PLURAL_TO_SINGULAR[type] ?? type; - if (singular === 'flow') return { item: data, notices: [] }; + if (singular === 'flow') return { item: data, notices: [], todos: [] }; const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; const item = applyConversionsToStoredItem(singular, data, { onNotice: (n) => { notices.push(n); onNotice?.(n); }, + onTodo: (t) => { + todos.push(t); + }, }); - return { item, notices }; + return { item, notices, todos }; } /** @@ -16714,6 +16725,16 @@ export class ObjectStackProtocolImplementation implements * is simply outside its reach. Re-author the item under the canonical * type (`PUT /meta//`) and drop the non-canonical * row. + * - **Sites the chain leaves as stored for want of a lossless rewrite** + * (#17321, ADR-0087 D3's structured TODO). A conversion that recognises + * 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. + * 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`, + * never `canonical`. Like the skip classes above, TODOs do not flip + * {@link storedMigrationClean}: no run of this pass could clear them. */ async migrateStoredMetadata(request: { /** Write. Omitted / false = preview: reports what it would do, writes nothing. */ @@ -16811,6 +16832,7 @@ export class ObjectStackProtocolImplementation implements packageId, state, notices: [] as StoredMigrationNotice[], + todos: [] as StoredMigrationTodo[], }; // An already-canonical row is counted, never itemised: on a healthy // deployment that is every row, and a report listing all of them @@ -16941,12 +16963,43 @@ 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. - const { item, notices } = flowResult - ? { item: flowResult.storable, notices: flowResult.notices } + const { item, notices, todos } = flowResult + ? { item: flowResult.storable, notices: flowResult.notices, todos: [] as ConversionTodoNotice[] } : this.convertStoredItemDetailed(singular, body); const changed = flowResult ? item !== body : notices.length > 0; + // [#17321] A site the chain left as stored — no lossless rewrite + // exists for it (ADR-0087 D3: a structured TODO, never silence) — + // rides on the row whatever its outcome, and is what keeps a row + // with nothing to persist from being counted `canonical` below. + const flattenedTodos: StoredMigrationTodo[] = todos.map((t) => ({ + conversionId: t.conversionId, + surface: t.surface, + from: t.from, + path: t.path, + reason: t.reason, + message: t.message, + })); if (!changed) { - record({ ...base, outcome: 'canonical' }); + if (flattenedTodos.length === 0) { + record({ ...base, outcome: 'canonical' }); + continue; + } + // Nothing to persist, and not on protocol either: every site the + // chain recognised here is one it has no lossless rewrite for. + // `skipped` — outside what a body-canonicalization pass can do, + // BY RULING rather than by capability (the conversion must not + // flatten a combinator) — so, like every skip class, it is + // printed with each site and does not flip + // `storedMigrationClean`: no run of this pass could ever clear it. + record({ + ...base, + todos: flattenedTodos, + 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.', + }); continue; } const flattened: StoredMigrationNotice[] = notices.map((n) => ({ @@ -16959,7 +17012,7 @@ export class ObjectStackProtocolImplementation implements })); if (!apply) { - record({ ...base, notices: flattened, outcome: 'pending' }); + record({ ...base, notices: flattened, todos: flattenedTodos, outcome: 'pending' }); continue; } @@ -16976,7 +17029,7 @@ export class ObjectStackProtocolImplementation implements actor: request.actor ?? 'migrate-stored', ...(organizationId ? { organizationId } : {}), }); - record({ ...base, notices: flattened, outcome: 'rewritten' }); + record({ ...base, notices: flattened, todos: flattenedTodos, outcome: 'rewritten' }); } catch (e: any) { // [#8333 · P13] `rows[].reason` is REPORT DATA — it rides on the // migration report, not on a thrown message — so no HTTP @@ -16999,6 +17052,7 @@ export class ObjectStackProtocolImplementation implements record({ ...base, notices: flattened, + todos: flattenedTodos, outcome: 'failed', reason: clientFacingFailureText( e, diff --git a/packages/metadata-protocol/src/stored-migration.ts b/packages/metadata-protocol/src/stored-migration.ts index a9bd7698eed..91b5d28ab74 100644 --- a/packages/metadata-protocol/src/stored-migration.ts +++ b/packages/metadata-protocol/src/stored-migration.ts @@ -91,6 +91,29 @@ export interface StoredMigrationNotice { message: string; } +/** + * One site the chain recognised as a pre-protocol shape and LEFT AS STORED, + * because no conversion can rewrite it without changing what it means — + * flattened from the spec's `ConversionTodoNotice` to the fields an operator + * acts on. ADR-0087 D3's model: conversion where lossless, a structured TODO + * otherwise — never silence. A TODO emits no conversion notice, so without + * this list the row would read as already on protocol. + */ +export interface StoredMigrationTodo { + /** The `MetadataConversion.id` whose surface the site is on. */ + conversionId: string; + /** Dotted surface that conversion governs. */ + surface: string; + /** The pre-protocol shape left in the stored body. */ + from: string; + /** Where in the item the site is, e.g. `pages[0].regions[0].components[1].properties.filter`. */ + path: string; + /** Why no lossless rewrite exists — the block and the part that blocks it — and what the hand rewrite must decide. */ + reason: string; + /** The chain's own human-facing line. */ + message: string; +} + /** Per-row result. Rows the chain left alone (`canonical`) are counted, not listed. */ export interface StoredMigrationRow { /** `sys_metadata.id` — the row this is about, so an operator can go look at it. */ @@ -106,6 +129,14 @@ export interface StoredMigrationRow { outcome: StoredMigrationOutcome; /** The conversions this row carries. Empty unless the chain rewrote something. */ notices: StoredMigrationNotice[]; + /** + * The sites the chain left as stored for a hand rewrite. Empty unless a + * conversion reported one. Orthogonal to `outcome`: a row can convert one + * site and leave another (`pending` / `rewritten` / `failed` with TODOs), or + * carry nothing but TODOs — then there is nothing to persist, and the row is + * `skipped`, never `canonical` (see {@link storedMigrationClean}). + */ + todos: StoredMigrationTodo[]; /** Why a `skipped` / `failed` row was not rewritten. Absent otherwise. */ reason?: string; } @@ -150,12 +181,28 @@ export interface StoredMigrationReport { * that no run of that command could ever clear — a gate failing on a condition * its own tool has no lever for. So it reports, loudly and per row, and leaves * the verdict to mean what it has always meant: nothing left to CONVERT. + * + * A fourth skip class follows the same rule for the same reason: a row whose + * only finding is a conversion TODO ({@link StoredMigrationRow.todos}) — a site + * the chain recognised as a pre-protocol shape and left as stored because no + * lossless rewrite exists (a filter carrying `$or`, say, which a flat rule list + * cannot spell). This pass has no lever for it BY RULING — the conversion must + * not flatten it — so it is `skipped`, printed with every site and why, and + * does not flip this verdict. TODOs never move it in either direction: a row + * that also converts something stays `pending` / `rewritten` / `failed` exactly + * as it would without them. */ export function storedMigrationClean(report: StoredMigrationReport): boolean { return report.pending === 0 && report.failed === 0; } -/** Render a run for a terminal. One line per non-canonical row, notices nested. */ +/** + * Render a run for a terminal. One line per non-canonical row, its notices and + * its TODOs nested under it — wherever the row is listed, since a TODO rides on + * a converting, skipped or failed row alike — and one closing line counting + * the TODOs, so "did it convert my row" is answered by the output itself. + * A run with no TODO renders exactly as it did before TODOs existed. + */ export function formatStoredMigrationReport(report: StoredMigrationReport): string[] { const lines: string[] = []; lines.push( @@ -175,6 +222,7 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri for (const n of row.notices) { lines.push(` ${n.conversionId}: ${n.from} → ${n.to} at ${n.path}`); } + pushTodos(lines, row); } } @@ -188,6 +236,7 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri lines.push(`⚠ ${skipped.length} row(s) are outside this pass — each row's reason says why:`); for (const row of skipped) { lines.push(` • ${row.type}/${row.name} ${describeScope(row)} — ${row.reason ?? 'skipped'}`); + pushTodos(lines, row); } } @@ -196,9 +245,20 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri lines.push(`✗ ${failed.length} row(s) could not be rewritten:`); for (const row of failed) { lines.push(` • ${row.type}/${row.name} ${describeScope(row)} — ${row.reason ?? 'failed'}`); + pushTodos(lines, row); } } + const withTodos = report.rows.filter((r) => r.todos.length > 0); + if (withTodos.length > 0) { + const sites = withTodos.reduce((sum, r) => sum + r.todos.length, 0); + lines.push( + `☐ TODO: ${sites} site(s) in ${withTodos.length} row(s) are left as stored — no conversion ` + + 'can rewrite them without changing what they mean, so no run of this pass will. Each TODO ' + + 'line above names the site and why; rewrite it by hand.', + ); + } + if (report.scanned === 0) { // "Nothing to convert" and "nothing was looked at" are different claims, // and only the first is a pass. A run pointed at the wrong project — the @@ -208,7 +268,9 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri 'a deployment that has never authored metadata looks like, and also what running ' + 'from the wrong project root looks like — check the database named above.', ); - } else if (converting.length === 0 && failed.length === 0) { + } else if (converting.length === 0 && failed.length === 0 && withTodos.length === 0) { + // Not printed beside a TODO: a site left as stored is exactly a row that is + // NOT on protocol, and the line would contradict the list above it. lines.push( `✓ Every row examined is already on protocol ${report.protocol} — ` + 'the read-path conversion pass is a no-op here.', @@ -217,6 +279,13 @@ export function formatStoredMigrationReport(report: StoredMigrationReport): stri return lines; } +/** A row's TODOs, nested under its line the way its notices are. */ +function pushTodos(lines: string[], row: StoredMigrationRow): void { + for (const t of row.todos) { + lines.push(` TODO ${t.conversionId}: ${t.from} left as stored at ${t.path} — ${t.reason}`); + } +} + /** `[org=… package=… draft]` — only the parts that are not the default. */ function describeScope(row: StoredMigrationRow): string { const parts: string[] = []; diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0a1e68f207f..3241234ae65 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -612,6 +612,19 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'surfaces a loud diagnostic instead (ADR-0078: never silent). A callback payload, not a ' + 'thrown error and not a response body.', }, + { + code: 'OS_METADATA_CONVERSION_TODO', + file: 'packages/spec/src/conversions/apply.ts', + shape: 'objlitconst', + door: 'none', + verdict: 'foreign-vocabulary', + why: + 'The TODO twin of the OS_METADATA_CONVERTED row: handed to `onTodo` when a conversion ' + + 'recognises a pre-protocol shape it has no lossless rewrite for and leaves the site as ' + + "stored (ADR-0087 D3's structured TODO, never silence). The stored-metadata migration " + + 'pass flattens it onto its report rows, where `os migrate meta --stored` prints it; it ' + + 'carries no `code` there. A callback payload, not a thrown error and not a response body.', + }, { code: 'ERR_BULK_PER_ROW_HOOK_LIMIT', file: 'packages/spec/src/data/bulk-write-hook-conformance.ts', diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index de2efc0c9ac..14be0a2a329 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -16,9 +16,11 @@ import { ALL_CONVERSIONS } from './registry.js'; import { CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, + CONVERSION_TODO_CODE, type ConversionConflictNotice, type ConversionContext, type ConversionNotice, + type ConversionTodoNotice, } from './types.js'; export interface ApplyConversionsOptions { @@ -64,6 +66,19 @@ export interface ApplyConversionsOptions { * Populated by the runtime load seam; absent on the build/validate seam. */ onConflict?: (notice: ConversionConflictNotice) => void; + /** + * Sink for each structured **TODO** — a site a conversion recognised as a + * pre-protocol shape and left as stored, because no lossless rewrite exists + * for it (ADR-0087 D3's model: convert where lossless, a structured TODO + * otherwise). The site is left unchanged whether or not a sink is supplied; + * 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. + */ + onTodo?: (todo: ConversionTodoNotice) => void; /** * Node types that are live in this environment. Supplied by the runtime load * seam so open-namespace renames can detect a collision with a live owner @@ -112,7 +127,7 @@ export function applyConversions( stack: Record, options: ApplyConversionsOptions = {}, ): Record { - const { onNotice, onConflict, reservedNodeTypes, includeRetired = false, excludeConversionIds } = options; + const { onNotice, onConflict, onTodo, reservedNodeTypes, includeRetired = false, excludeConversionIds } = options; const excluded = excludeConversionIds && excludeConversionIds.length > 0 ? new Set(excludeConversionIds) : null; @@ -144,6 +159,20 @@ export function applyConversions( message: `[protocol] ${detail.reason} (ADR-0087 conversion '${conversion.id}').`, }) : undefined, + reportTodo: onTodo + ? (detail) => + onTodo({ + code: CONVERSION_TODO_CODE, + conversionId: conversion.id, + surface: conversion.surface, + from: detail.from, + path: detail.path, + reason: detail.reason, + message: + `[protocol] left ${conversion.surface} at ${detail.path} as stored — ADR-0087 ` + + `conversion '${conversion.id}' has no lossless rewrite for it: ${detail.reason}`, + }) + : undefined, }; current = conversion.apply( current, diff --git a/packages/spec/src/conversions/index.ts b/packages/spec/src/conversions/index.ts index e2b2f4ffd6d..e286559ee95 100644 --- a/packages/spec/src/conversions/index.ts +++ b/packages/spec/src/conversions/index.ts @@ -12,12 +12,15 @@ export { CONVERSION_CONFLICT_CODE, CONVERSION_NOTICE_CODE, + CONVERSION_TODO_CODE, type ConversionApplication, type ConversionConflictDetail, type ConversionConflictNotice, type ConversionContext, type ConversionFixture, type ConversionNotice, + type ConversionTodoDetail, + type ConversionTodoNotice, type MetadataConversion, } from './types.js'; export { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 7de8807d3f6..dc1d0948aca 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -31,6 +31,7 @@ import { RETIRED_SUB_DAY_INTERVALS } from '../data/analytics.zod.js'; import { FILTER_ARRAY_LOGIC_KEYWORDS, FILTER_OPERATORS, + LOGICAL_OPERATORS, isFilterAST, parseFilterAST, } from '../data/filter.zod.js'; @@ -10354,8 +10355,49 @@ function ruleOperatorForFilterOperator(op: string): ViewFilterOperator | undefin } /** - * The record form `{ field: value | { $op: value, … }, … }` → rules, or - * `undefined` when any part of it has no lossless rule spelling. + * A legacy filter's verdict: the rule array it maps to losslessly, or — when + * some part of it has no lossless rule spelling — why not. + * + * `declined` completes the sentence "this filter …" and names the part that + * blocks the rewrite. It is what the entry's structured TODO carries + * (`context.reportTodo`, ADR-0087 D3: a TODO where the conversion is not + * lossless, never silence), so an operator reading `os migrate meta --stored` + * learns which site was left as stored and what the hand rewrite must decide. + * The verdict itself is unchanged by it: a declined filter is left exactly as + * stored, as it always was. + */ +type FilterMapping = { rules: MappedFilterRule[] } | { declined: string }; + +/** `` `a` `` / `` `a` and `b` `` — key names as a TODO reason quotes them. */ +function quoteKeys(keys: readonly string[]): string { + return keys.map((key) => `\`${key}\``).join(' and '); +} + +/** + * Why a record with top-level `$` keys is left as stored — the combinators + * named first, because naming them is what the ruling asks of the TODO. + */ +function dollarKeysReason(keys: readonly string[]): string { + const combinators = keys.filter((key) => (LOGICAL_OPERATORS as readonly string[]).includes(key)); + const others = keys.filter((key) => !combinators.includes(key)); + if (combinators.length > 0) { + const alsoNotFields = others.length === 0 + ? '' + : ` (its top-level ${quoteKeys(others)} ${others.length === 1 ? 'is' : 'are'} not a field either)`; + return `carries the combinator${combinators.length === 1 ? '' : 's'} ${quoteKeys(combinators)}` + + `${alsoNotFields}: a rule array's rules only AND, so \`$or\` and \`$not\` have no rule ` + + 'spelling and `$and` only the separate rules it joins, and a combinator is never flattened — ' + + 'that would change which rows the filter selects. Decide which rows it should select, and ' + + 'write the rules that select exactly those'; + } + return `carries the top-level key${others.length === 1 ? '' : 's'} ${quoteKeys(others)}, which ` + + `${others.length === 1 ? 'is' : 'are'} not a field — a rule names a field, so there is no rule ` + + `spelling for ${others.length === 1 ? 'it' : 'them'}`; +} + +/** + * The record form `{ field: value | { $op: value, … }, … }` → rules, or why + * some part of it has no lossless rule spelling. * * All-or-nothing on purpose: the rules AND, so converting the keys that map and * leaving the rest out would WIDEN what the filter selects. A top-level `$` key @@ -10367,30 +10409,62 @@ function ruleOperatorForFilterOperator(op: string): ViewFilterOperator | undefin * null, so that key constrains nothing today, while an `equals null` rule would * test IS NULL. An empty operator object is declined for the same reason — it * constrains nothing, and no rule says "nothing". + * + * Every top-level `$` key is judged before any field key, so the reason names + * the combinator even when a field key beside it would decline as well. The + * order moves only the reason, never the verdict: any declined part leaves the + * whole record as stored. */ -function recordFilterToRules(record: Record): MappedFilterRule[] | undefined { +function recordFilterToRules(record: Record): FilterMapping { + const dollarKeys = Object.keys(record).filter((key) => key.startsWith('$')); + if (dollarKeys.length > 0) return { declined: dollarKeysReason(dollarKeys) }; + const equals = normalizeFilterOperator('eq') as ViewFilterOperator; const rules: MappedFilterRule[] = []; for (const [field, value] of Object.entries(record)) { - if (field.startsWith('$')) return undefined; if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - rules.push({ field, operator: normalizeFilterOperator('eq') as ViewFilterOperator, value }); + rules.push({ field, operator: equals, value }); continue; } - if (!isRecordForm(value)) return undefined; + if (value === null) { + return { + declined: `has the key \`${field}\` set to null: the renderer skips a null-valued key, so ` + + `today it constrains nothing, while an \`${equals}\` rule would test for null. Drop the ` + + 'key, or write a rule that tests for null if that is what it should select', + }; + } + if (!isRecordForm(value)) { + return { + declined: `has the key \`${field}\` set to ` + + `${Array.isArray(value) ? 'an array' : 'a value that is neither a scalar nor an operator object'}, ` + + `which has no lossless \`${equals}\` rule`, + }; + } const operators = Object.entries(value); - if (operators.length === 0) return undefined; + if (operators.length === 0) { + return { + declined: `has the key \`${field}\` set to an empty operator object, which constrains ` + + 'nothing — and no rule says "nothing". Drop the key', + }; + } for (const [op, comparand] of operators) { const operator = ruleOperatorForFilterOperator(op); - if (!operator) return undefined; + if (!operator) { + return { + declined: op.startsWith('$') + ? `compares \`${field}\` with \`${op}\`, which has no rule operator that lowers back to it` + : `has the key \`${field}\` set to a nested object whose key \`${op}\` is not a filter operator`, + }; + } rules.push({ field, operator, value: comparand }); } } - return rules; + return { rules }; } /** * A single-level ObjectQL AST — one comparison `[field, op, value]`, or a flat - * list of them (implicit AND) — → rules, or `undefined`. + * list of them (implicit AND) — → rules, or why not; `undefined` when the + * value is not an AST at all, so it is not this conversion's form. * * `isFilterAST` is the recogniser (the spec's own, so an array of rule objects * is never mistaken for one). An `and` / `or` group, or a list nesting one, is @@ -10401,7 +10475,7 @@ function recordFilterToRules(record: Record): MappedFilterRule[ * rule reaches too — and read back from the `$` operator it produced, so * neither table is copied here. */ -function astFilterToRules(ast: readonly unknown[]): MappedFilterRule[] | undefined { +function astFilterToRules(ast: readonly unknown[]): FilterMapping | undefined { if (!isFilterAST(ast)) return undefined; const isLogicKeyword = (token: unknown): boolean => typeof token === 'string' @@ -10416,11 +10490,39 @@ function astFilterToRules(ast: readonly unknown[]): MappedFilterRule[] | undefin let comparisons: ReadonlyArray; if (isComparison(ast)) comparisons = [ast]; else if (ast.every(isComparison)) comparisons = ast as ReadonlyArray; - else return undefined; + else { + // Name the groups by walking only the AST's structural positions — a logic + // node's children and a list's members — so a comparison's own value array + // is never read as a group. + const groups = new Set(); + const visit = (node: unknown): void => { + if (!Array.isArray(node) || node.length === 0) return; + if (isLogicKeyword(node[0])) { + groups.add(String(node[0]).toLowerCase()); + node.slice(1).forEach(visit); + } else if (Array.isArray(node[0])) { + node.forEach(visit); + } + }; + visit(ast); + return { + declined: groups.size > 0 + ? `is a nested ObjectQL AST holding ${quoteKeys([...groups])} group${groups.size === 1 ? '' : 's'}, ` + + 'and only a single-level AST (one comparison, or a flat list of them) has a lossless rule ' + + 'spelling: a combinator is never flattened — that would change which rows the filter ' + + 'selects. Decide which rows it should select, and write the rules that select exactly those' + : 'is an ObjectQL AST that is neither one comparison `[field, operator, value]` nor a flat ' + + 'list of them, so it has no lossless rule spelling', + }; + } const rules: MappedFilterRule[] = []; for (const node of comparisons) { const [field, op] = node; + const unspellable: FilterMapping = { + declined: `compares \`${field}\` with the AST operator \`${op}\`, which has no rule operator ` + + 'that lowers back to it', + }; let operator: ViewFilterOperator | undefined; const folded = normalizeFilterOperator(op); if ((VIEW_FILTER_OPERATORS as readonly string[]).includes(folded)) { @@ -10430,9 +10532,9 @@ function astFilterToRules(ast: readonly unknown[]): MappedFilterRule[] | undefin try { lowered = parseFilterAST([...node]) as Record | undefined; } catch { - return undefined; + return unspellable; } - if (!lowered || Object.keys(lowered).length !== 1 || !(field in lowered)) return undefined; + if (!lowered || Object.keys(lowered).length !== 1 || !(field in lowered)) return unspellable; const condition = lowered[field]; if (isRecordForm(condition)) { const ops = Object.keys(condition); @@ -10441,29 +10543,39 @@ function astFilterToRules(ast: readonly unknown[]): MappedFilterRule[] | undefin // A bare comparand is the implicit-equality lowering of `=` / `==`. operator = normalizeFilterOperator('eq') as ViewFilterOperator; } - if (!operator) return undefined; + if (!operator) return unspellable; } rules.push(node.length === 3 ? { field, operator, value: node[2] } : { field, operator }); } - return rules; + return { rules }; } /** - * The rule array a legacy `filter` value maps to losslessly, or `undefined` - * when it is not a legacy form (already a rule array, or some other value the - * door judges on its own) or has a part with no lossless rule spelling. + * The rule array a legacy `filter` value maps to losslessly, or why a legacy + * value has none; `undefined` when the value is not a legacy form at all + * (already a rule array, or some other value the door judges on its own) — the + * one case that is neither converted nor reported. * * The last gate is the DOOR's own: every produced rule must parse against * `ViewFilterRuleSchema`, so the conversion never writes a value the next save * refuses — a comparand shape the operator cannot take, an empty `icontains`, * a field-reference object. Such a filter is left exactly as stored. */ -function legacyFilterToRuleArray(value: unknown): MappedFilterRule[] | undefined { - let rules: MappedFilterRule[] | undefined; - if (isRecordForm(value)) rules = recordFilterToRules(value); - else if (Array.isArray(value)) rules = astFilterToRules(value); - if (!rules) return undefined; - return rules.every((rule) => ViewFilterRuleSchema.safeParse(rule).success) ? rules : undefined; +function legacyFilterToRuleArray(value: unknown): FilterMapping | undefined { + let mapping: FilterMapping | undefined; + if (isRecordForm(value)) mapping = recordFilterToRules(value); + else if (Array.isArray(value)) mapping = astFilterToRules(value); + if (!mapping || 'declined' in mapping) return mapping; + for (const rule of mapping.rules) { + const parsed = ViewFilterRuleSchema.safeParse(rule); + if (parsed.success) continue; + const first = parsed.error.issues[0]?.message; + return { + declined: `would become the rule \`${JSON.stringify(rule)}\`, which this door refuses itself` + + (first ? ` (${first})` : ''), + }; + } + return mapping; } /** @@ -10492,13 +10604,23 @@ function legacyFilterToRuleArray(value: unknown): MappedFilterRule[] | undefined * (`record-source.ts`, `resolveRecordSourceConfig`) plus the bare-array * `data` the spec declares on the kanban, calendar and timeline blocks: * `data: { provider: 'value', … }`, `data: [ … ]`, and a truthy `staticData`. + * + * Answers with the shape it found, spelled for the TODO that names why the + * node's filters were left as stored, or `undefined` for an object-bound node. */ -function rendersInlineRows(properties: unknown): boolean { - if (!isDict(properties)) return false; +function rendersInlineRows(properties: unknown): string | undefined { + if (!isDict(properties)) return undefined; const { data, staticData } = properties; - if (Array.isArray(data)) return true; - if (isDict(data) && data.provider === 'value') return true; - return Boolean(staticData); + if (Array.isArray(data)) return 'a `data` array'; + if (isDict(data) && data.provider === 'value') return "`data: { provider: 'value' }`"; + return staticData ? '`staticData`' : undefined; +} + +/** How a TODO names the page component a filter sits on: its type, and its `id` when it has one. */ +function describeBlock(component: Dict): string { + const type = typeof component.type === 'string' ? `the \`${component.type}\` block` : 'this component'; + const id = typeof component.id === 'string' && component.id.length > 0 ? ` \`${component.id}\`` : ''; + return `${type}${id}`; } /** @@ -10523,7 +10645,7 @@ function rendersInlineRows(properties: unknown): boolean { * * Values are carried verbatim, value placeholders and date macros included. * - * ## What is left exactly as stored — `legacyFilterToRuleArray` answers `undefined` + * ## What is left exactly as stored — `legacyFilterToRuleArray` answers `declined` * * A record carrying `$and` / `$or` / `$not` (or any top-level `$` key), an AST * `and` / `or` group, an operator the rule vocabulary does not spell (`$null`, @@ -10537,13 +10659,19 @@ function rendersInlineRows(properties: unknown): boolean { * and `$not` that changes which rows the page selects, which is the option the * ruling excluded. Such a row keeps loading unchanged (the stored-row seam * does not validate) and is refused at its door on its next save, with the - * prescription that door gives for it. ⚠️ The ruled report of these rows — a - * structured TODO `os migrate meta --stored` prints — is NOT delivered here: - * the conversion layer has no TODO channel, and adding one reaches past - * `packages/spec` (the dispatcher error-vocabulary ledger in - * `packages/runtime`, and the stored pass in `packages/metadata-protocol`, - * whose change signal is a conversion notice and which counts a row that - * emitted none as canonical). + * prescription that door gives for it. + * + * ## Every site left as stored is reported — `context.reportTodo` + * + * Ruling item 2's other half: each legacy filter this entry leaves as stored + * is reported as a structured TODO (ADR-0087 D3's model — conversion where + * lossless, a TODO otherwise), naming its path, the block it sits on, and what + * blocks the rewrite — the combinator by name, for a combinator. + * `os migrate meta --stored` lists them under their row, so an operator can + * answer "did it convert my row" from that output. The one value at a door + * that is neither converted nor reported is one that is not a legacy form at + * all — already a rule array, or a value the door judges on its own. Reporting + * changes nothing that is written: every declined site stays byte-identical. * * ## Reach * @@ -10579,20 +10707,42 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { + 'filter of a component whose rows are inline (`data: { provider: \'value\' }`, a `data` ' + 'array, `staticData`) is left exactly as stored and is refused at its door on its next save (one filter ' + 'orthography platform-wide, objectui#6206; #17321 ruling B)', - apply(stack, emit) { - const rewrite = (holder: Dict, key: string, basePath: string): Dict => { - if (!(key in holder)) return holder; - const value = holder[key]; - const rules = legacyFilterToRuleArray(value); - if (!rules) return holder; - emit({ from: JSON.stringify(value), to: JSON.stringify(rules), path: `${basePath}.${key}` }); - return { ...holder, [key]: rules }; - }; - + apply(stack, emit, context) { return mapPageComponents(stack, (component, path) => { // Inline rows: every filter of this node stays as stored, the binding's // included. Its children are separate nodes and are judged on their own. - if (rendersInlineRows(component.properties)) return component; + const inline = rendersInlineRows(component.properties); + const block = describeBlock(component); + + const rewrite = (holder: Dict, key: string, basePath: string): Dict => { + if (!(key in holder)) return holder; + const value = holder[key]; + const mapping = legacyFilterToRuleArray(value); + // Not a legacy form (already the rule array, or a value the door judges + // on its own): neither converted nor reported. + if (!mapping) return holder; + const at = `${basePath}.${key}`; + // The filter's own blocker first — it would decline on an object-bound + // block too, and it is what names the combinator — then the node's. + const declined = 'declined' in mapping + ? mapping.declined + : inline + ? `sits on a block whose rows are inline (${inline}): its renderer matches this filter ` + + 'against those rows in the record dialect, where a rule array would exclude every ' + + 'row, so no rewrite here is lossless' + : undefined; + if (declined !== undefined) { + context?.reportTodo?.({ + path: at, + from: JSON.stringify(value), + reason: `On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged ` + + 'and is refused at this door on its next save.', + }); + return holder; + } + emit({ from: JSON.stringify(value), to: JSON.stringify(mapping.rules), path: at }); + return { ...holder, [key]: mapping.rules }; + }; let next = component; diff --git a/packages/spec/src/conversions/stored.ts b/packages/spec/src/conversions/stored.ts index 50bc697ed45..19164ab3fa9 100644 --- a/packages/spec/src/conversions/stored.ts +++ b/packages/spec/src/conversions/stored.ts @@ -66,6 +66,10 @@ const STORED_ONLY_COLLECTIONS: Readonly> = { * Options for {@link applyConversionsToStoredItem} — everything * {@link ApplyConversionsOptions} offers except `includeRetired`, which this * seam pins to `true` (the whole point of the stored pass; see module doc). + * + * `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. */ export type StoredConversionOptions = Omit; diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index e85c12cc771..339adf4026f 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -36,6 +36,22 @@ export const CONVERSION_NOTICE_CODE = 'OS_METADATA_CONVERTED' as const; */ export const CONVERSION_CONFLICT_CODE = 'OS_METADATA_CONVERSION_CONFLICT' as const; +/** + * Stable code for a **conversion TODO** — a site a conversion recognised as a + * pre-protocol shape and deliberately LEFT AS STORED, because no lossless + * rewrite exists for it. + * + * ADR-0087 D3's model, applied to one site: convert where lossless, a + * structured TODO otherwise — never silence. A conversion that declines part + * of its own surface (a filter carrying a combinator the canonical shape + * cannot spell, say) returns that site unchanged, so no + * {@link ConversionNotice} fires for it; without this channel the pass that + * 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. + */ +export const CONVERSION_TODO_CODE = 'OS_METADATA_CONVERSION_TODO' as const; + /** * A structured deprecation notice emitted once per applied conversion. * @@ -94,6 +110,45 @@ export interface ConversionConflictNotice { message: string; } +/** + * The per-site detail a conversion reports when it recognises a pre-protocol + * shape on its surface and leaves it as stored, because no lossless rewrite + * exists (see {@link CONVERSION_TODO_CODE}). + */ +export interface ConversionTodoDetail { + /** Where the site is, e.g. `pages[0].regions[0].components[1].properties.filter`. */ + path: string; + /** The pre-protocol shape left in place, as seen in the source (serialized). */ + from: string; + /** + * Why no lossless rewrite exists — naming the part that blocks it (the + * combinator, the operator, the key) and the node it sits on — and what the + * hand rewrite has to decide. Actionable, like a conflict's `reason`. + */ + reason: string; +} + +/** + * A structured TODO notice: a site left as stored because no lossless rewrite + * exists. Same machine-first shape as {@link ConversionNotice}; different code, + * and no `to` — nothing was converted. + */ +export interface ConversionTodoNotice { + code: typeof CONVERSION_TODO_CODE; + /** The {@link MetadataConversion.id} whose surface the site is on. */ + conversionId: string; + /** Dotted surface the conversion governs. */ + surface: string; + /** The pre-protocol shape left in place. */ + from: string; + /** Where in the stack the site is. */ + path: string; + /** Why no lossless rewrite exists (see {@link ConversionTodoDetail.reason}). */ + reason: string; + /** Derived, human-facing one-liner. */ + message: string; +} + /** * Environment-supplied context for a conversion pass. Empty on the pure * build/validate seam (no runtime registry to consult); populated on the @@ -110,6 +165,12 @@ export interface ConversionContext { reservedNodeTypes?: ReadonlySet; /** Sink for a refused rewrite (see {@link ConversionConflictDetail}). */ reportConflict?: (detail: ConversionConflictDetail) => void; + /** + * Sink for a site left as stored because no lossless rewrite exists (see + * {@link ConversionTodoDetail}). Absent unless the caller asked for TODOs; a + * conversion calls it optionally and leaves the site unchanged either way. + */ + reportTodo?: (detail: ConversionTodoDetail) => void; } /** @@ -177,7 +238,9 @@ export interface MetadataConversion { * new) stack and calls `emit` once per rewritten site. A conversion over an * open namespace consults `context` (when supplied) to refuse — and report via * `context.reportConflict` — a rewrite whose old token is a live name; a - * conversion over a closed surface ignores `context`. + * conversion over a closed surface ignores `context`. A conversion that + * recognises a pre-protocol shape on its surface but has no lossless rewrite + * for it leaves the site unchanged and reports it via `context.reportTodo`. */ apply( stack: Record, From d23e918a06f9a7f88f9465c4f0e2751f3c619a76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:11:30 +0000 Subject: [PATCH 02/12] =?UTF-8?q?fix(spec):=20narrow=20the=20TODO=20branch?= =?UTF-8?q?=20explicitly=20=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- ...ponent-filter-record-to-rule-array.test.ts | 26 +++++++++----- packages/spec/src/conversions/registry.ts | 34 +++++++++---------- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index ad2c93bd9c1..4db984367a7 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -38,10 +38,10 @@ import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; import { ComponentPropsMap } from '../ui/component.zod.js'; import { ElementDataSourceSchema } from '../ui/page.zod.js'; -import { collectConversionNotices } from './apply.js'; +import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS } from './registry.js'; import { applyConversionsToStoredItem } from './stored.js'; -import type { ConversionNotice } from './types.js'; +import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js'; const ID = 'page-component-filter-record-to-rule-array'; const RULE_FORM = '[{ field, operator, value }, ...]'; @@ -58,15 +58,25 @@ function componentOf(stack: Dict): Dict { return ((page.regions as Dict[])[0]!.components as Dict[])[0]!; } -/** Run the WHOLE chain (retired entries included, as the data-at-rest seams do). */ -function convert(stack: Dict): { stack: Dict; notices: ConversionNotice[] } { - return collectConversionNotices(structuredClone(stack), { includeRetired: true }); +/** + * Run the WHOLE chain (retired entries included, as the data-at-rest seams do), + * collecting the notices AND the TODOs — the site this entry leaves as stored. + */ +function convert(stack: Dict): { stack: Dict; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } { + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversions(structuredClone(stack), { + includeRetired: true, + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }); + return { stack: out, notices, todos }; } /** The value a converted `properties.filter` on an `object-grid` ends up holding. */ -function gridFilter(filter: unknown): { value: unknown; notices: ConversionNotice[] } { - const { stack, notices } = convert(pageWith({ type: 'object-grid', properties: { objectName: 'deal', filter } })); - return { value: (componentOf(stack).properties as Dict).filter, notices }; +function gridFilter(filter: unknown): { value: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } { + const { stack, notices, todos } = convert(pageWith({ type: 'object-grid', properties: { objectName: 'deal', filter } })); + return { value: (componentOf(stack).properties as Dict).filter, notices, todos }; } describe('§0 premises', () => { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dc1d0948aca..aa51375526a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10724,24 +10724,24 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { const at = `${basePath}.${key}`; // The filter's own blocker first — it would decline on an object-bound // block too, and it is what names the combinator — then the node's. - const declined = 'declined' in mapping - ? mapping.declined - : inline - ? `sits on a block whose rows are inline (${inline}): its renderer matches this filter ` - + 'against those rows in the record dialect, where a rule array would exclude every ' - + 'row, so no rewrite here is lossless' - : undefined; - if (declined !== undefined) { - context?.reportTodo?.({ - path: at, - from: JSON.stringify(value), - reason: `On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged ` - + 'and is refused at this door on its next save.', - }); - return holder; + let declined: string; + if ('declined' in mapping) { + declined = mapping.declined; + } else if (inline) { + declined = `sits on a block whose rows are inline (${inline}): its renderer matches this ` + + 'filter against those rows in the record dialect, where a rule array would exclude ' + + 'every row, so no rewrite here is lossless'; + } else { + emit({ from: JSON.stringify(value), to: JSON.stringify(mapping.rules), path: at }); + return { ...holder, [key]: mapping.rules }; } - emit({ from: JSON.stringify(value), to: JSON.stringify(mapping.rules), path: at }); - return { ...holder, [key]: mapping.rules }; + context?.reportTodo?.({ + path: at, + from: JSON.stringify(value), + reason: `On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged ` + + 'and is refused at this door on its next save.', + }); + return holder; }; let next = component; From 7736d215b03679126b11001944cda56ad9da3d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:26:49 +0000 Subject: [PATCH 03/12] =?UTF-8?q?test(spec):=20pin=20one=20TODO=20per=20de?= =?UTF-8?q?cline=20branch=20of=20the=20record-filter=20conversion=20?= =?UTF-8?q?=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- ...ponent-filter-record-to-rule-array.test.ts | 275 +++++++++++++++--- packages/spec/src/conversions/registry.ts | 18 +- packages/spec/src/conversions/stored.test.ts | 35 ++- 3 files changed, 277 insertions(+), 51 deletions(-) diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 4db984367a7..cedfe909d26 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -23,7 +23,12 @@ * re-saves cleanly; * §6 the reach is the family, derived from the schema rather than recalled; * §7 the jurisdiction: retired from the authoring funnel (Clause-② no — an - * author is still refused), replayed at rest and by the migration chain. + * author is still refused), replayed at rest and by the migration chain; + * §8 the other half of ruling item 2: every legacy filter left as stored is + * REPORTED as a structured TODO (`onTodo`) naming its path, its block and + * what blocks the rewrite — the combinator by name — one per decline + * branch; a filter that converts, and a value that is no legacy form at + * all, reports none; and reporting writes nothing. */ import { describe, expect, it } from 'vitest'; @@ -207,52 +212,71 @@ describe('§1 the ruled subset converts to the exact rule array', () => { }); describe('§2 what has no lossless rule spelling is left byte-identical', () => { - // The ruled boundary first: a combinator is never flattened. - const COMBINATOR_ROWS: ReadonlyArray = [ - ['$or', { $or: [{ stage: 'open' }, { stage: 'won' }] }], - ['$and', { $and: [{ stage: 'open' }, { amount: { $gt: 1 } }] }], - ['$not', { $not: { stage: 'lost' } }], - ['$or beside a field key', { owner_id: 'u1', $or: [{ stage: 'open' }, { stage: 'won' }] }], - ['an AST `and` group', ['and', ['stage', '=', 'open'], ['amount', '>', 1]]], - ['an AST `or` group', ['or', ['stage', '=', 'open'], ['stage', '=', 'won']]], - ['a flat list nesting an `or` group', [['owner_id', '=', 'u1'], ['or', ['a', '=', 1], ['b', '=', 2]]]], + // The ruled boundary first: a combinator is never flattened. The third + // column is what the site's TODO must name (ruling item 2: "naming the + // page/block and the combinator"). + const COMBINATOR_ROWS: ReadonlyArray = [ + ['$or', { $or: [{ stage: 'open' }, { stage: 'won' }] }, 'the combinator `$or`'], + ['$and', { $and: [{ stage: 'open' }, { amount: { $gt: 1 } }] }, 'the combinator `$and`'], + ['$not', { $not: { stage: 'lost' } }, 'the combinator `$not`'], + ['$or beside a field key', { owner_id: 'u1', $or: [{ stage: 'open' }, { stage: 'won' }] }, 'the combinator `$or`'], + ['an AST `and` group', ['and', ['stage', '=', 'open'], ['amount', '>', 1]], 'an `and` group'], + ['an AST `or` group', ['or', ['stage', '=', 'open'], ['stage', '=', 'won']], 'an `or` group'], + ['a flat list nesting an `or` group', [['owner_id', '=', 'u1'], ['or', ['a', '=', 1], ['b', '=', 2]]], 'an `or` group'], ]; - it.each(COMBINATOR_ROWS)('%s', (_name, filter) => { + it.each(COMBINATOR_ROWS)('%s', (_name, filter, named) => { const before = pageWith({ type: 'object-kanban', properties: { objectName: 'deal', filter } }); - const { stack, notices } = convert(before); + const { stack, notices, todos } = convert(before); expect((componentOf(stack).properties as Dict).filter).toEqual(filter); expect(notices).toEqual([]); + // Left as stored, and SAID so: one TODO, at this door, naming the combinator. + expect(todos).toHaveLength(1); + expect(todos[0]!.path).toBe('pages[0].regions[0].components[0].properties.filter'); + expect(todos[0]!.reason).toContain(named); + expect(todos[0]!.reason).toContain('the `object-kanban` block'); // Copy-on-write: nothing on the way was rebuilt either. const frozen = structuredClone(before); expect(collectConversionNotices(frozen, { includeRetired: true }).stack).toBe(frozen); }); - const DECLINED_ROWS: ReadonlyArray = [ + // The third column is what the site's TODO must say, or `null` for the one + // row that is not a legacy form at all (see the last row). + const DECLINED_ROWS: ReadonlyArray = [ // The renderer at the pin skips a null key (constrains nothing); a rule would test IS NULL. - ['a null value', { owner_id: null }], - ['a null value beside a mappable key', { stage: 'open', owner_id: null }], + ['a null value', { owner_id: null }, 'has the key `owner_id` set to null'], + ['a null value beside a mappable key', { stage: 'open', owner_id: null }, 'has the key `owner_id` set to null'], // Direction lives in the VALUE — not in the one operator table. - ['`$null`', { deleted_at: { $null: true } }], - ['`$exists`', { deleted_at: { $exists: false } }], - ['an operator that is not a FilterCondition operator', { name: { $regex: 'a.c' } }], - ['a mis-cased operator', { amount: { $Gt: 1 } }], - ['an empty operator object', { amount: {} }], - ['a nested non-operator object', { owner: { id: 'u1' } }], - ['an array in equality position', { tags: ['a', 'b'] }], - ['a top-level `$` key that is not a combinator', { $text: 'acme' }], - ['a field-reference comparand', { amount: { $gt: { $field: 'budget' } } }], - ['a comparand the door refuses (`in` needs a list)', { stage: { $in: 'open' } }], - ['an empty `icontains` comparand', { name: { $icontains: '' } }], - ['an AST operator with no rule word (`like`)', [['name', 'like', '%acme%']]], - ['an AST scalar comparison with no value', [['amount', '>']]], - ['a mixed list of a rule object and an AST tuple', [{ field: 'a', operator: 'equals', value: 1 }, ['b', '=', 2]]], + ['`$null`', { deleted_at: { $null: true } }, 'compares `deleted_at` with `$null`'], + ['`$exists`', { deleted_at: { $exists: false } }, 'compares `deleted_at` with `$exists`'], + ['an operator that is not a FilterCondition operator', { name: { $regex: 'a.c' } }, 'compares `name` with `$regex`'], + ['a mis-cased operator', { amount: { $Gt: 1 } }, 'compares `amount` with `$Gt`'], + ['an empty operator object', { amount: {} }, 'has the key `amount` set to an empty operator object'], + ['a nested non-operator object', { owner: { id: 'u1' } }, 'a nested object whose key `id` is not a filter operator'], + ['an array in equality position', { tags: ['a', 'b'] }, 'has the key `tags` set to an array'], + ['a top-level `$` key that is not a combinator', { $text: 'acme' }, 'carries the top-level key `$text`, which is not a field'], + ['a field-reference comparand', { amount: { $gt: { $field: 'budget' } } }, 'which this door refuses itself'], + ['a comparand the door refuses (`in` needs a list)', { stage: { $in: 'open' } }, 'which this door refuses itself'], + ['an empty `icontains` comparand', { name: { $icontains: '' } }, 'which this door refuses itself'], + ['an AST operator with no rule word (`like`)', [['name', 'like', '%acme%']], 'compares `name` with the AST operator `like`'], + ['an AST scalar comparison with no value', [['amount', '>']], 'which the AST itself does not lower'], + // Neither a record nor an AST (`isFilterAST` refuses the rule object in it), + // so not this conversion's form: no TODO. The door's own element-level + // refusal (`filter.1`) is what names it. + ['a mixed list of a rule object and an AST tuple', [{ field: 'a', operator: 'equals', value: 1 }, ['b', '=', 2]], null], ]; - it.each(DECLINED_ROWS)('%s', (_name, filter) => { - const { value, notices } = gridFilter(filter); + it.each(DECLINED_ROWS)('%s', (_name, filter, said) => { + const { value, notices, todos } = gridFilter(filter); expect(value).toEqual(filter); expect(notices).toEqual([]); + if (said === null) { + expect(todos).toEqual([]); + return; + } + expect(todos).toHaveLength(1); + expect(todos[0]!.reason).toContain(said); + expect(todos[0]!.from).toBe(JSON.stringify(filter)); }); it('all-or-nothing: a declined key keeps the mappable keys beside it from converting', () => { @@ -263,9 +287,11 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => it('the `filter` of a component outside the family is not this entry\'s surface', () => { const before = pageWith({ type: 'record:related_list', properties: { objectName: 'deal', filter: { a: 1 } } }); - const { stack, notices } = convert(before); + const { stack, notices, todos } = convert(before); expect((componentOf(stack).properties as Dict).filter).toEqual({ a: 1 }); expect(notices).toEqual([]); + // Not this entry's door, so not this entry's TODO either. + expect(todos).toEqual([]); }); describe('a component whose rows are INLINE keeps every filter as stored', () => { @@ -274,45 +300,56 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => // rows are inline, and ValueDataSource matches the record form but excludes // EVERY row for a rule array. So there the rewrite is not lossless — and the // binding is composed into that same `filter`, so it stays as stored too. - const INLINE: ReadonlyArray = [ - ['object-map', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }], - ['object-tree', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }], - ['object-gantt', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }], - ['object-calendar', '`staticData`', { staticData: [{ stage: 'open' }] }], - ['object-map', 'an EMPTY `staticData` (still the value rung)', { staticData: [] }], - ['object-kanban', 'a bare `data` array', { data: [{ stage: 'open' }] }], + const INLINE: ReadonlyArray = [ + ['object-map', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"], + ['object-tree', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"], + ['object-gantt', '`data: { provider: value }`', { data: { provider: 'value', items: [{ stage: 'open' }] } }, "(`data: { provider: 'value' }`)"], + ['object-calendar', '`staticData`', { staticData: [{ stage: 'open' }] }, '(`staticData`)'], + ['object-map', 'an EMPTY `staticData` (still the value rung)', { staticData: [] }, '(`staticData`)'], + ['object-kanban', 'a bare `data` array', { data: [{ stage: 'open' }] }, '(a `data` array)'], ]; - it.each(INLINE)('%s with %s', (type, _shape, inline) => { + it.each(INLINE)('%s with %s', (type, _shape, inline, named) => { const before = pageWith({ type, dataSource: { object: 'deal', filter: { owner_id: 'u1' } }, properties: { objectName: 'deal', ...inline, filter: { stage: 'open' } }, }); - const { stack, notices } = convert(before); + const { stack, notices, todos } = convert(before); const component = componentOf(stack); expect((component.properties as Dict).filter).toEqual({ stage: 'open' }); expect((component.dataSource as Dict).filter).toEqual({ owner_id: 'u1' }); expect(notices).toEqual([]); + // Both filters would have converted on an object-bound block; here each + // is left as stored and reported, naming the inline shape. + expect(todos.map((t) => t.path)).toEqual([ + 'pages[0].regions[0].components[0].dataSource.filter', + 'pages[0].regions[0].components[0].properties.filter', + ]); + for (const todo of todos) { + expect(todo.reason).toContain(`sits on a block whose rows are inline ${named}`); + expect(todo.reason).toContain(`the \`${type}\` block`); + } const frozen = structuredClone(before); expect(collectConversionNotices(frozen, { includeRetired: true }).stack).toBe(frozen); }); it('`defaultFilters` on an inline-row grid stays as stored too', () => { - const { value, notices } = (() => { - const { stack, notices: n } = convert(pageWith({ + const { value, notices, todos } = (() => { + const { stack, notices: n, todos: t } = convert(pageWith({ type: 'object-grid', properties: { data: { provider: 'value', items: [] }, defaultFilters: { stage: 'open' } }, })); - return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n }; + return { value: (componentOf(stack).properties as Dict).defaultFilters, notices: n, todos: t }; })(); expect(value).toEqual({ stage: 'open' }); expect(notices).toEqual([]); + expect(todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); }); it('control: the same filter on an object-bound block of the same type converts', () => { for (const data of [undefined, { provider: 'object', object: 'deal' }]) { - const { stack, notices } = convert( + const { stack, notices, todos } = convert( pageWith({ type: 'object-map', dataSource: { object: 'deal', filter: { owner_id: 'u1' } }, @@ -327,16 +364,18 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => { field: 'owner_id', operator: 'equals', value: 'u1' }, ]); expect(notices).toHaveLength(2); + expect(todos).toEqual([]); } }); }); it('`defaultFilters` is converted on the grid only', () => { - const { stack, notices } = convert( + const { stack, notices, todos } = convert( pageWith({ type: 'object-kanban', properties: { objectName: 'deal', defaultFilters: { a: 1 } } }), ); expect((componentOf(stack).properties as Dict).defaultFilters).toEqual({ a: 1 }); expect(notices).toEqual([]); + expect(todos).toEqual([]); }); }); @@ -359,6 +398,11 @@ describe('§3 identity and idempotence', () => { expect(twice.stack).toBe(once.stack); expect(twice.notices).toEqual([]); }); + + it('a rule array reports nothing either — it is not a legacy form', () => { + const { todos } = gridFilter([{ field: 'stage', operator: 'equals', value: 'open' }]); + expect(todos).toEqual([]); + }); }); describe('§4 lossless, measured over the declared vocabularies', () => { @@ -508,3 +552,142 @@ describe('§7 jurisdiction — retired from authoring, replayed at rest and by t expect(result.applied.filter((a) => a.conversionId === ID)).toHaveLength(1); }); }); + +describe('§8 the TODO channel — every site left as stored is reported (ruling item 2)', () => { + // One row per DECLINE BRANCH of the entry, named for the branch in + // `registry.ts` it exercises. §2 pins the same property over the rows the + // first half of this card measured; this table is the branch census the + // report enumerates, so a branch added without a TODO shows up here. + const BRANCHES: ReadonlyArray = [ + ['record: a combinator key', { $or: [{ a: 1 }, { b: 2 }] }, 'carries the combinator `$or`'], + ['record: a combinator beside a non-combinator `$` key', { $and: [{ a: 1 }], $text: 'x' }, 'carries the combinator `$and` (its top-level `$text` is not a field either)'], + ['record: two combinators', { $or: [{ a: 1 }], $not: { b: 2 } }, 'carries the combinators `$or` and `$not`'], + ['record: a non-combinator `$` key', { $where: 'x' }, 'carries the top-level key `$where`, which is not a field'], + ['record: a combinator is named even when a field key would decline first', { owner_id: null, $or: [{ a: 1 }] }, 'carries the combinator `$or`'], + ['record: a null value', { a: null }, 'has the key `a` set to null'], + ['record: an array value', { a: [1, 2] }, 'has the key `a` set to an array'], + ['record: a non-plain object value', { a: new Date(0) }, 'set to a value that is neither a scalar nor an operator object'], + ['record: an empty operator object', { a: {} }, 'set to an empty operator object'], + ['record: an operator outside the one table', { a: { $exists: true } }, 'compares `a` with `$exists`'], + ['record: a nested object that is not an operator object', { a: { b: 1 } }, 'nested object whose key `b` is not a filter operator'], + ['AST: an `and` / `or` group', ['or', ['a', '=', 1], ['b', '=', 2]], 'is a nested ObjectQL AST holding an `or` group'], + ['AST: neither one comparison nor a flat list', ['a', '=', 1, 2], 'neither one comparison `[field, operator, value]` nor a flat list'], + ['AST: a comparison the AST refuses to lower', [['a', '>']], 'holds the AST comparison `["a",">"]`, which the AST itself does not lower'], + ['AST: an operator with no rule word', [['a', 'ilike', '%x%']], 'compares `a` with the AST operator `ilike`'], + ['door: a mapped rule the door refuses', { a: { $in: 'x' } }, 'would become the rule `{"field":"a","operator":"in","value":"x"}`, which this door refuses itself ('], + ]; + + it.each(BRANCHES)('%s', (_branch, filter, said) => { + const { value, notices, todos } = gridFilter(filter); + expect(value).toEqual(filter); + expect(notices).toEqual([]); + expect(todos).toHaveLength(1); + const [todo] = todos; + expect(todo!.code).toBe(CONVERSION_TODO_CODE); + expect(todo!.conversionId).toBe(ID); + expect(todo!.surface).toBe(ALL_CONVERSIONS.find((c) => c.id === ID)!.surface); + expect(todo!.path).toBe('pages[0].regions[0].components[0].properties.filter'); + expect(todo!.from).toBe(JSON.stringify(filter)); + expect(todo!.reason).toContain(said); + // The tail every TODO of this entry carries: what happens to the row next. + expect(todo!.reason).toMatch(/Left as stored, it keeps loading unchanged and is refused at this door on its next save\.$/); + expect(todo!.message).toContain(`at ${todo!.path} as stored`); + expect(todo!.message).toContain(todo!.reason); + }); + + it('the inline-row branch: a filter that WOULD convert, left as stored because of the node', () => { + const { value, todos } = (() => { + const r = convert(pageWith({ type: 'object-map', properties: { staticData: [], filter: { a: 1 } } })); + return { value: (componentOf(r.stack).properties as Dict).filter, todos: r.todos }; + })(); + expect(value).toEqual({ a: 1 }); + expect(todos).toHaveLength(1); + expect(todos[0]!.reason).toContain('sits on a block whose rows are inline (`staticData`)'); + }); + + it('on an inline-row node the filter\'s own blocker wins — a combinator is still named', () => { + const { todos } = convert( + pageWith({ type: 'object-map', properties: { staticData: [], filter: { $or: [{ a: 1 }] } } }), + ); + expect(todos).toHaveLength(1); + expect(todos[0]!.reason).toContain('carries the combinator `$or`'); + }); + + it('names the block by its type, and by its `id` when it has one', () => { + const { todos } = convert( + pageWith({ type: 'object-kanban', id: 'pipeline_board', properties: { objectName: 'deal', filter: { $or: [] } } }), + ); + expect(todos[0]!.reason).toMatch(/^On the `object-kanban` block `pipeline_board`, this filter carries/); + const anonymous = convert(pageWith({ type: 'object-kanban', properties: { objectName: 'deal', filter: { $or: [] } } })); + expect(anonymous.todos[0]!.reason).toMatch(/^On the `object-kanban` block, this filter carries/); + }); + + it('control: every shape that converts losslessly reports NO TODO', () => { + for (const filter of [ + { stage: 'open', score: 3 }, + { amount: { $gt: 100, $lte: 5000 } }, + [['owner_id', '=', '{current_user_id}'], ['deleted_at', 'is_null']], + ['status', '!=', 'done'], + {}, + ]) { + const { value, notices, todos } = gridFilter(filter); + expect(Array.isArray(value), JSON.stringify(filter)).toBe(true); + expect(notices, JSON.stringify(filter)).toHaveLength(1); + expect(todos, JSON.stringify(filter)).toEqual([]); + } + }); + + it('control: a value that is not a legacy form is neither converted nor reported', () => { + for (const filter of [[], [{ field: 'a', operator: 'equals', value: 1 }], 'status = open', 42]) { + const { value, notices, todos } = gridFilter(filter); + expect(value, JSON.stringify(filter)).toEqual(filter); + expect(notices, JSON.stringify(filter)).toEqual([]); + expect(todos, JSON.stringify(filter)).toEqual([]); + } + }); + + it('one page, two blocks: the lossless filter converts, the combinator one is a TODO', () => { + const { stack, notices, todos } = convert({ + pages: [ + { + name: 'pipeline', + regions: [ + { + name: 'main', + components: [ + { type: 'object-grid', properties: { objectName: 'deal', filter: { stage: 'open' } } }, + { type: 'object-kanban', properties: { objectName: 'deal', filter: { $or: [{ stage: 'open' }, { stage: 'won' }] } } }, + ], + }, + ], + }, + ], + }); + const components = ((stack.pages as Dict[])[0]!.regions as Dict[])[0]!.components as Dict[]; + expect((components[0]!.properties as Dict).filter).toEqual([{ field: 'stage', operator: 'equals', value: 'open' }]); + expect((components[1]!.properties as Dict).filter).toEqual({ $or: [{ stage: 'open' }, { stage: 'won' }] }); + expect(notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.filter']); + expect(todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].properties.filter']); + }); + + it('the fixture: its two stored-as-is sites are its two TODOs', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID)!; + const { todos } = convert(entry.fixture.before); + expect(todos.map((t) => [t.path, t.reason.slice(0, 40)])).toEqual([ + ['pages[0].regions[0].components[1].properties.filter', 'On the `object-kanban` block, this filte'], + ['pages[0].regions[0].components[2].properties.filter', 'On the `object-map` block, this filter s'], + ]); + }); + + it('reporting writes nothing: every decline yields the same stack with or without a sink', () => { + for (const [, filter] of BRANCHES) { + const before = pageWith({ type: 'object-grid', properties: { objectName: 'deal', filter } }); + const withSink = convert(before).stack; + const frozen = structuredClone(before); + // No sink at all — the authoring funnel's and every other seam's posture. + const without = applyConversions(frozen, { includeRetired: true }); + expect(without).toBe(frozen); + expect(JSON.stringify(withSink)).toBe(JSON.stringify(before)); + } + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index aa51375526a..c2eb503ac21 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10368,6 +10368,12 @@ function ruleOperatorForFilterOperator(op: string): ViewFilterOperator | undefin */ type FilterMapping = { rules: MappedFilterRule[] } | { declined: string }; +/** A refusal's first sentence, without its full stop — a TODO reason quotes only that much. */ +function firstSentence(message: string): string { + const end = message.search(/\.(\s|$)/); + return (end === -1 ? message : message.slice(0, end)).trim(); +} + /** `` `a` `` / `` `a` and `b` `` — key names as a TODO reason quotes them. */ function quoteKeys(keys: readonly string[]): string { return keys.map((key) => `\`${key}\``).join(' and '); @@ -10507,7 +10513,8 @@ function astFilterToRules(ast: readonly unknown[]): FilterMapping | undefined { visit(ast); return { declined: groups.size > 0 - ? `is a nested ObjectQL AST holding ${quoteKeys([...groups])} group${groups.size === 1 ? '' : 's'}, ` + ? `is a nested ObjectQL AST holding ${groups.size === 1 ? 'an ' : ''}${quoteKeys([...groups])} ` + + `group${groups.size === 1 ? '' : 's'}, ` + 'and only a single-level AST (one comparison, or a flat list of them) has a lossless rule ' + 'spelling: a combinator is never flattened — that would change which rows the filter ' + 'selects. Decide which rows it should select, and write the rules that select exactly those' @@ -10531,8 +10538,11 @@ function astFilterToRules(ast: readonly unknown[]): FilterMapping | undefined { let lowered: Record | undefined; try { lowered = parseFilterAST([...node]) as Record | undefined; - } catch { - return unspellable; + } catch (error) { + return { + declined: `holds the AST comparison \`${JSON.stringify(node)}\`, which the AST itself does not ` + + `lower (${firstSentence(error instanceof Error ? error.message : String(error))})`, + }; } if (!lowered || Object.keys(lowered).length !== 1 || !(field in lowered)) return unspellable; const condition = lowered[field]; @@ -10572,7 +10582,7 @@ function legacyFilterToRuleArray(value: unknown): FilterMapping | undefined { const first = parsed.error.issues[0]?.message; return { declined: `would become the rule \`${JSON.stringify(rule)}\`, which this door refuses itself` - + (first ? ` (${first})` : ''), + + (first ? ` (${first.trim().replace(/\.$/, '')})` : ''), }; } return mapping; diff --git a/packages/spec/src/conversions/stored.test.ts b/packages/spec/src/conversions/stored.test.ts index 4e4c6ed057e..488d5969bc9 100644 --- a/packages/spec/src/conversions/stored.test.ts +++ b/packages/spec/src/conversions/stored.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { applyConversionsToStoredItem } from './stored.js'; -import type { ConversionNotice } from './types.js'; +import { CONVERSION_TODO_CODE, type ConversionNotice, type ConversionTodoNotice } from './types.js'; // The stored pass replays the FULL chain (ADR-0087 addendum, #3903): a row at // rest was written under some past protocol and has no author to be taught by @@ -210,4 +210,37 @@ describe('applyConversionsToStoredItem (stored sys_metadata rows, #3903)', () => expect(out.nodes[0]!.type).toBe('webhook'); expect(conflicts).toEqual(['webhook']); }); + + // [#17321] The TODO lane rides through the stored seam the way the conflict + // lane does: `StoredConversionOptions` omits only `includeRetired`, so + // `onTodo` reaches the (retired, replayed-at-rest) filter conversion. + it('threads the TODO sink through — a stored page filter left as stored is reported, not silent', () => { + const page = { + name: 'pipeline', + regions: [ + { + name: 'main', + components: [ + { type: 'object-grid', properties: { objectName: 'deal', filter: { stage: 'open' } } }, + { type: 'object-kanban', properties: { objectName: 'deal', filter: { $or: [{ stage: 'open' }, { stage: 'won' }] } } }, + ], + }, + ], + }; + const notices: ConversionNotice[] = []; + const todos: ConversionTodoNotice[] = []; + const out = applyConversionsToStoredItem('page', page, { + onNotice: (n) => notices.push(n), + onTodo: (t) => todos.push(t), + }) as typeof page; + const components = out.regions[0]!.components as Array<{ properties: { filter: unknown } }>; + expect(components[0]!.properties.filter).toEqual([{ field: 'stage', operator: 'equals', value: 'open' }]); + // Byte-identical: the combinator is never flattened, only named. + expect(components[1]!.properties.filter).toEqual({ $or: [{ stage: 'open' }, { stage: 'won' }] }); + expect(notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.filter']); + expect(todos.map((t) => [t.code, t.conversionId, t.path])).toEqual([ + [CONVERSION_TODO_CODE, 'page-component-filter-record-to-rule-array', 'pages[0].regions[0].components[1].properties.filter'], + ]); + expect(todos[0]!.reason).toContain('`$or`'); + }); }); From be9fd2ce94df58dc44d6eead3bc1239050a68d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:49:26 +0000 Subject: [PATCH 04/12] =?UTF-8?q?feat(metadata-protocol):=20list=20TODO=20?= =?UTF-8?q?rows=20in=20the=20stored=20migration=20report;=20changesets=20a?= =?UTF-8?q?nd=20docs=20=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .changeset/17321-conversion-todo-channel.md | 50 ++++++ .../17321-record-filter-d2-conversion.md | 12 +- content/docs/deployment/cli.mdx | 5 +- .../src/protocol.stored-migration.test.ts | 168 ++++++++++++++++++ ...ponent-filter-record-to-rule-array.test.ts | 2 +- packages/spec/src/conversions/registry.ts | 17 +- 6 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 .changeset/17321-conversion-todo-channel.md diff --git a/.changeset/17321-conversion-todo-channel.md b/.changeset/17321-conversion-todo-channel.md new file mode 100644 index 00000000000..4c243c98736 --- /dev/null +++ b/.changeset/17321-conversion-todo-channel.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,metadata-protocol): `os migrate meta --stored` lists every stored page filter the record-filter conversion leaves as stored, as a TODO naming the page, the block and why — the ADR-0087 D3 TODO channel (#17321, ruling B item 2) + +**Clause-②: yes** — `@objectstack/spec` gains public exports (`CONVERSION_TODO_CODE`, +`ConversionTodoDetail`, `ConversionTodoNotice`, and the optional `ApplyConversionsOptions.onTodo` +and `ConversionContext.reportTodo`), and `@objectstack/metadata-protocol` gains +`StoredMigrationTodo` and `StoredMigrationRow.todos`. No door's accept set moves, and nothing +that was left as stored before starts converting: every stored body is rewritten exactly as it +was. + +**What was silent.** The D2 conversion `page-component-filter-record-to-rule-array` leaves a +stored filter as stored wherever no lossless rule-array spelling exists — above all a record +carrying `$and` / `$or` / `$not`, which is never flattened. It emitted nothing for such a site, +and `os migrate meta --stored` reads conversion notices as its change signal, so a page whose +only legacy filter carried a combinator was reported as **already on protocol**. + +**What it says now.** Each such site is a structured TODO (code `OS_METADATA_CONVERSION_TODO`) +carrying its path, the shape left in place, and a reason that names the block (its type, and its +`id` when it has one) and what blocks the rewrite — the combinator by name, the operator +(`$null`, `$exists`, an AST `like`), the null or array value, the rule the door would refuse, or +the inline rows the block renders. The stored pass lists them under their row, whatever the +row's outcome: + +```text +⚠ 1 row(s) are outside this pass — each row's reason says why: + • page/pipeline_board [env-wide] — the conversion chain rewrites nothing here: it left 1 site(s) of this row as stored, … + TODO page-component-filter-record-to-rule-array: {"$or":[…]} left as stored at pages[0].regions[0].components[0].properties.filter — On the `object-kanban` block, this filter carries the combinator `$or`: … +☐ TODO: 1 site(s) in 1 row(s) are left as stored — no conversion can rewrite them without changing what they mean, so no run of this pass will. … +``` + +The same list is `rows[].todos` in `--json` and in the `POST /api/v1/meta/_migrate-stored` +report. A run with no TODO prints exactly what it printed before. + +**Outcome and exit code.** A row whose only finding is TODOs has nothing to persist and is now +reported `skipped` (it was `canonical`). Like every other skip class it does not change the +run's exit code: no run of this pass can clear it, because the conversion must not flatten a +combinator — it is the hand rewrite's to decide. A row that also converts something keeps the +outcome its conversion gives it, with its TODOs listed beside its notices. Measured through the +write path: on `--apply`, a row whose leftover sits in a block's `properties.filter` or +`properties.defaultFilters` is rewritten (its lossless filters persist; the metadata API's save +does not refuse block props by component type), while a leftover in `dataSource.filter` fails +the save, and the row's TODO says why. + +**For code calling the conversion layer.** `onTodo` and `reportTodo` are optional. Only the +stored-metadata pass passes a sink today; every other seam leaves the site as stored silently, +exactly as before. diff --git a/.changeset/17321-record-filter-d2-conversion.md b/.changeset/17321-record-filter-d2-conversion.md index 4904fede10f..39e4549c99c 100644 --- a/.changeset/17321-record-filter-d2-conversion.md +++ b/.changeset/17321-record-filter-d2-conversion.md @@ -45,8 +45,10 @@ are **inline** (`data: { provider: 'value', … }`, a `data` array, or `staticDa stored: the `object-map`, `object-tree`, `object-calendar` and `object-gantt` renderers match that filter against their own rows in an in-memory data source that reads the record form but excludes every row for a rule array, so a rewrite there would empty the block. The same filter -on a block that queries an object converts. Such a page keeps loading and rendering unchanged and is refused at its -`filter` door on its next save — and for a combinator record that refusal no longer renders the -combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule -spells it. `os migrate meta --stored` does not list these rows yet: a row the conversion leaves -as stored reports there as already on protocol. +on a block that queries an object converts. Such a page keeps loading and rendering unchanged, +and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a +block's `properties.filter` / `properties.defaultFilters` only as the component-props gate's +advisory finding (`os validate`) — a re-save through the metadata API is not refused there, +measured — and for a combinator record that refusal no longer renders the combinator as a field +(`{ field: '$or', … }`); it names the combinator and says why no rule spells it. +`os migrate meta --stored` lists each filter left as stored as a TODO under its row. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 8e83dfbd294..637c4624368 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1195,6 +1195,7 @@ can produce, because both supply a live one. | Types with no repository write path (`agent`) | Their write path records no history and would force a draft live — a half-write is worse than leaving the row to the read path | | 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 | **Flows are covered, and cost one extra plugin.** Flow-node conversions carry an open-namespace conflict guard that has to consult the *live* executor registry @@ -1221,8 +1222,8 @@ something the platform can depend on. What running it buys is hygiene (cleaner diffs, exports and history from here on, and the recurring boot notices go quiet) plus one thing that was previously unobtainable: **you can assert it.** A run with nothing left to do exits `0`; a deployment with rows still carrying -an old dialect exits `1`. So "my metadata is on protocol N" becomes a check -rather than a belief. +an old dialect this pass can convert exits `1`. So "my metadata is on protocol N" +becomes a check rather than a belief. Note the division of labour with the default mode: `os migrate meta --from N` lists the edits **an author's source** needs and reads no database; `--stored` diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 440da2ed1e6..5ecc923b5dc 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -626,3 +626,171 @@ describe('formatStoredMigrationReport (#4327)', () => { expect(text).toMatch(/conditionalRequired → requiredWhen/); }); }); + +describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, not silence (#17321)', () => { + // Ruling item 2 (decision batch #121 item 4, B): a stored page filter + // carrying `$and` / `$or` / `$not` is passed through unchanged and + // reported as a structured TODO naming the page/block and the combinator — + // `os migrate meta --stored` prints the list, so the operator can answer + // "did it convert my row" from that output. + // + // Before the TODO lane existed, the conversion emitted NO notice for such a + // site, this pass reads notices as its change signal, and so a page whose + // only legacy filter carried a combinator was counted `canonical` — + // "already on protocol" about a row whose next save is refused. + const COMBINATOR = { $or: [{ stage: 'open' }, { stage: 'won' }] }; + const pageRow = (name: string, components: unknown[]) => ({ + type: 'page', + name, + metadata: { name, label: 'Pipeline', type: 'app', regions: [{ name: 'main', components }] }, + }); + const kanban = (filter: unknown) => ({ type: 'object-kanban', properties: { objectName: 'deal', filter } }); + const grid = (filter: unknown) => ({ type: 'object-grid', properties: { objectName: 'deal', filter } }); + const combinatorPage = pageRow('pipeline_board', [kanban(COMBINATOR)]); + const losslessPage = pageRow('open_deals', [grid({ stage: 'open' })]); + const mixedPage = pageRow('deal_desk', [grid({ stage: 'open' }), kanban(COMBINATOR)]); + const canonicalPage = pageRow('won_deals', [grid([{ field: 'stage', operator: 'equals', value: 'won' }])]); + + it('a combinator-only page is listed with a TODO naming its path and the combinator — not counted canonical', async () => { + const { engine, tables } = makeStubEngine([combinatorPage]); + const before = JSON.stringify(metaRows(tables)); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.canonical).toBe(0); + expect(report.skipped).toBe(1); + expect(report.rows).toHaveLength(1); + const row = report.rows[0]!; + expect(row).toMatchObject({ type: 'page', name: 'pipeline_board', outcome: 'skipped', notices: [] }); + expect(row.reason).toMatch(/left 1 site\(s\) of this row as stored/); + expect(row.todos).toHaveLength(1); + const todo = row.todos[0]!; + expect(todo.conversionId).toBe('page-component-filter-record-to-rule-array'); + expect(todo.path).toBe('pages[0].regions[0].components[0].properties.filter'); + expect(todo.from).toBe(JSON.stringify(COMBINATOR)); + expect(todo.reason).toContain('the `object-kanban` block'); + expect(todo.reason).toContain('the combinator `$or`'); + // A TODO is reporting only: nothing written, and the filter is never flattened. + expect(JSON.stringify(metaRows(tables))).toBe(before); + + // The operator reads it off the rendered report: the row, the path, the combinator. + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toContain('page/pipeline_board [env-wide]'); + expect(text).toContain(`TODO page-component-filter-record-to-rule-array: ${JSON.stringify(COMBINATOR)} left as stored at pages[0].regions[0].components[0].properties.filter`); + expect(text).toContain('`$or`'); + expect(text).toMatch(/☐ TODO: 1 site\(s\) in 1 row\(s\) are left as stored/); + // …and is never told the opposite in the same breath. + expect(text).not.toMatch(/already on protocol/); + }); + + it('does not flip `storedMigrationClean` — a skip class this pass has no lever for, by ruling', async () => { + const { engine, tables } = makeStubEngine([combinatorPage]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const preview = await protocol.migrateStoredMetadata(); + expect(storedMigrationClean(preview)).toBe(true); + + // An apply run writes nothing for it either, and says the same thing. + const applied = await protocol.migrateStoredMetadata({ apply: true }); + expect(applied.rows[0]).toMatchObject({ outcome: 'skipped' }); + expect(applied.rows[0]!.todos).toHaveLength(1); + expect(storedMigrationClean(applied)).toBe(true); + expect(historyRows(tables)).toHaveLength(0); + }); + + it('CONTROL — a losslessly folded filter produces no TODO, and its outcome is unchanged', async () => { + const { engine, tables } = makeStubEngine([losslessPage]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const preview = await protocol.migrateStoredMetadata(); + expect(preview.rows).toHaveLength(1); + expect(preview.rows[0]).toMatchObject({ outcome: 'pending', todos: [] }); + expect(preview.rows[0]!.notices.map((n) => n.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.filter', + ]); + expect(formatStoredMigrationReport(preview).join('\n')).not.toMatch(/TODO/); + + const applied = await protocol.migrateStoredMetadata({ apply: true }); + expect(applied.rows[0]).toMatchObject({ outcome: 'rewritten', todos: [] }); + expect(storedMigrationClean(applied)).toBe(true); + const stored = JSON.parse(metaRows(tables)[0]!.metadata); + expect(stored.regions[0].components[0].properties.filter).toEqual([ + { field: 'stage', operator: 'equals', value: 'open' }, + ]); + }); + + it('CONTROL — an already-canonical page is counted, never itemised, and reports no TODO', async () => { + const { engine } = makeStubEngine([canonicalPage]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + expect(report.canonical).toBe(1); + expect(report.rows).toHaveLength(0); + const text = formatStoredMigrationReport(report).join('\n'); + expect(text).toMatch(/already on protocol/); + expect(text).not.toMatch(/TODO/); + }); + + it('one row, two filters: the lossless one converts, the combinator one is a TODO on the same row', async () => { + const { engine } = makeStubEngine([mixedPage]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata(); + + // The outcome is what the notice alone makes it — TODOs never move it. + expect(report.pending).toBe(1); + expect(storedMigrationClean(report)).toBe(false); + const row = report.rows[0]!; + expect(row.outcome).toBe('pending'); + expect(row.notices.map((n) => n.path)).toEqual(['pages[0].regions[0].components[0].properties.filter']); + expect(row.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].properties.filter']); + expect(row.todos[0]!.reason).toContain('the combinator `$or`'); + + const text = formatStoredMigrationReport(report).join('\n'); + const rowAt = text.indexOf('page/deal_desk'); + const noticeAt = text.indexOf('page-component-filter-record-to-rule-array: {"stage":"open"} →'); + const todoAt = text.indexOf('TODO page-component-filter-record-to-rule-array:'); + // Both nested under the row, the conversion first. + expect(rowAt).toBeGreaterThanOrEqual(0); + expect(noticeAt).toBeGreaterThan(rowAt); + expect(todoAt).toBeGreaterThan(noticeAt); + }); + + it('MEASURED — the write path judges the two door kinds differently, and the TODO rides on either outcome', async () => { + // `properties.filter` sits in the page component's open `properties` bag: + // the runtime save door does not parse it by `type` (the props gate is + // `@objectstack/lint`'s, advisory). `dataSource.filter` is a declared key + // of the strict component schema, so the save door refuses it there. + const bindingMixed = pageRow('deal_room', [ + grid({ stage: 'open' }), + { type: 'object-kanban', dataSource: { object: 'deal', filter: COMBINATOR }, properties: { objectName: 'deal' } }, + ]); + const { engine, tables } = makeStubEngine([mixedPage, bindingMixed]); + const protocol = new ObjectStackProtocolImplementation(engine); + + const report = await protocol.migrateStoredMetadata({ apply: true }); + + const props = report.rows.find((r) => r.name === 'deal_desk')!; + expect(props.outcome).toBe('rewritten'); + expect(props.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].properties.filter']); + const binding = report.rows.find((r) => r.name === 'deal_room')!; + expect(binding.outcome).toBe('failed'); + expect(binding.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].dataSource.filter']); + + // The rewritten row persisted its lossless half; the combinator is byte-identical. + const stored = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_desk')!.metadata); + expect(stored.regions[0].components[0].properties.filter).toEqual([ + { field: 'stage', operator: 'equals', value: 'open' }, + ]); + expect(stored.regions[0].components[1].properties.filter).toEqual(COMBINATOR); + + // Re-run: what is left of the rewritten row is its TODO — skipped, never canonical. + const again = await protocol.migrateStoredMetadata({ apply: true, types: ['page'] }); + const rerun = again.rows.find((r) => r.name === 'deal_desk')!; + expect(rerun.outcome).toBe('skipped'); + expect(rerun.todos).toHaveLength(1); + expect(again.canonical).toBe(0); + }); +}); diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index cedfe909d26..36f50581776 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -590,7 +590,7 @@ describe('§8 the TODO channel — every site left as stored is reported (ruling expect(todo!.from).toBe(JSON.stringify(filter)); expect(todo!.reason).toContain(said); // The tail every TODO of this entry carries: what happens to the row next. - expect(todo!.reason).toMatch(/Left as stored, it keeps loading unchanged and is refused at this door on its next save\.$/); + expect(todo!.reason).toMatch(/Left as stored, it keeps loading unchanged, but it is not the rule-array form its door declares — rewrite it by hand\.$/); expect(todo!.message).toContain(`at ${todo!.path} as stored`); expect(todo!.message).toContain(todo!.reason); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index c2eb503ac21..dfaacfa8a61 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10668,8 +10668,14 @@ function describeBlock(component: Dict): string { * rewrite is not lossless. ⛔ A combinator is never flattened into the AND list — for `$or` * and `$not` that changes which rows the page selects, which is the option the * ruling excluded. Such a row keeps loading unchanged (the stored-row seam - * does not validate) and is refused at its door on its next save, with the - * prescription that door gives for it. + * does not validate), and its door's schema refuses the form — but WHERE that + * refusal lands differs by door, measured through `saveMetaItem` + * (`protocol.stored-migration.test.ts`): `dataSource.filter` is a declared key + * of the strict page-component schema, so the row's next save is refused + * there; `properties.filter` / `properties.defaultFilters` sit in the open + * `properties` bag the runtime save does not refuse by component type, so there + * the refusal is the component-props gate's (`@objectstack/lint`, advisory), + * and a re-save goes through. * * ## Every site left as stored is reported — `context.reportTodo` * @@ -10715,7 +10721,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { + '`equals` rules, `{ $op: v }` → the mapped operator, AST comparisons → one rule each); a ' + 'filter carrying `$and` / `$or` / `$not`, any part with no lossless rule spelling, or any ' + 'filter of a component whose rows are inline (`data: { provider: \'value\' }`, a `data` ' - + 'array, `staticData`) is left exactly as stored and is refused at its door on its next save (one filter ' + + 'array, `staticData`) is left exactly as stored — reported as a TODO, which `os migrate meta ' + + '--stored` lists — and is not the form its door declares (one filter ' + 'orthography platform-wide, objectui#6206; #17321 ruling B)', apply(stack, emit, context) { return mapPageComponents(stack, (component, path) => { @@ -10748,8 +10755,8 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { context?.reportTodo?.({ path: at, from: JSON.stringify(value), - reason: `On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged ` - + 'and is refused at this door on its next save.', + reason: `On ${block}, this filter ${declined}. Left as stored, it keeps loading unchanged, ` + + 'but it is not the rule-array form its door declares — rewrite it by hand.', }); return holder; }; From 23cd7bbed483ab17d1e7d7c503f7feed6d80fdce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:00:49 +0000 Subject: [PATCH 05/12] =?UTF-8?q?chore(spec):=20regenerate=20api-surface?= =?UTF-8?q?=20and=20export-origins=20for=20the=20TODO=20channel=20exports?= =?UTF-8?q?=20=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/api-surface/root.json | 3 +++ packages/spec/export-origins/root.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 63797641405..6d5d38d750c 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -42,6 +42,7 @@ "CONVERSIONS_BY_MAJOR (const)", "CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE (const)", + "CONVERSION_TODO_CODE (const)", "CapabilityClassification (interface)", "CapabilityEdition (type)", "CapabilityProviderStatus (type)", @@ -57,6 +58,8 @@ "ConversionContext (interface)", "ConversionFixture (interface)", "ConversionNotice (interface)", + "ConversionTodoDetail (interface)", + "ConversionTodoNotice (interface)", "CronExpressionInputSchema (const)", "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 820cab5b49f..11aa5bb3cf6 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -42,6 +42,7 @@ "CONVERSIONS_BY_MAJOR": "src/conversions/registry.ts#CONVERSIONS_BY_MAJOR (const)", "CONVERSION_CONFLICT_CODE": "src/conversions/types.ts#CONVERSION_CONFLICT_CODE (const)", "CONVERSION_NOTICE_CODE": "src/conversions/types.ts#CONVERSION_NOTICE_CODE (const)", + "CONVERSION_TODO_CODE": "src/conversions/types.ts#CONVERSION_TODO_CODE (const)", "CapabilityClassification": "src/kernel/platform-capabilities.ts#CapabilityClassification (interface)", "CapabilityEdition": "src/kernel/platform-capabilities.ts#CapabilityEdition (type)", "CapabilityProviderStatus": "src/kernel/platform-capabilities.ts#CapabilityProviderStatus (type)", @@ -57,6 +58,8 @@ "ConversionContext": "src/conversions/types.ts#ConversionContext (interface)", "ConversionFixture": "src/conversions/types.ts#ConversionFixture (interface)", "ConversionNotice": "src/conversions/types.ts#ConversionNotice (interface)", + "ConversionTodoDetail": "src/conversions/types.ts#ConversionTodoDetail (interface)", + "ConversionTodoNotice": "src/conversions/types.ts#ConversionTodoNotice (interface)", "CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)", "DatasourceMappingRule": "src/stack.zod.ts#DatasourceMappingRule (type)", "DatasourceMappingRuleSchema": "src/stack.zod.ts#DatasourceMappingRuleSchema (const)", From 7459968a65795668188a5b976ffae38fc8e819c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:09:03 +0000 Subject: [PATCH 06/12] =?UTF-8?q?fix(spec):=20the=20inline-row=20TODO=20na?= =?UTF-8?q?mes=20the=20pinned=20renderer's=20limit,=20not=20a=20protocol?= =?UTF-8?q?=20fact=20=E2=80=94=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- ...age-component-filter-record-to-rule-array.test.ts | 2 ++ packages/spec/src/conversions/registry.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts index 36f50581776..3235484d09c 100644 --- a/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts +++ b/packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts @@ -328,6 +328,8 @@ describe('§2 what has no lossless rule spelling is left byte-identical', () => ]); for (const todo of todos) { expect(todo.reason).toContain(`sits on a block whose rows are inline ${named}`); + // A renderer limit at the objectui pin, said as one — not a protocol fact. + expect(todo.reason).toContain('the objectui renderer this release pins cannot match a rule array'); expect(todo.reason).toContain(`the \`${type}\` block`); } const frozen = structuredClone(before); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index dfaacfa8a61..3c64746a734 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10617,6 +10617,12 @@ function legacyFilterToRuleArray(value: unknown): FilterMapping | undefined { * * Answers with the shape it found, spelled for the TODO that names why the * node's filters were left as stored, or `undefined` for an object-bound node. + * + * ⚠️ A fact about the RENDERER AT THE PIN, not about the protocol — and the TODO + * says so in those terms. objectui#10767 taught the inline-row matcher the rule + * array upstream, but the `.objectui-sha` pin this decline was measured at does + * not carry it, so the decline stands. Retiring it is owed once the pin moves + * past that fix, as its own change — never assumed from the upstream merge. */ function rendersInlineRows(properties: unknown): string | undefined { if (!isDict(properties)) return undefined; @@ -10745,9 +10751,9 @@ const pageComponentFilterRecordToRuleArray: MetadataConversion = { if ('declined' in mapping) { declined = mapping.declined; } else if (inline) { - declined = `sits on a block whose rows are inline (${inline}): its renderer matches this ` - + 'filter against those rows in the record dialect, where a rule array would exclude ' - + 'every row, so no rewrite here is lossless'; + declined = `sits on a block whose rows are inline (${inline}), and the objectui renderer ` + + 'this release pins cannot match a rule array against inline rows — it would exclude ' + + 'every row — so no rewrite here is lossless yet'; } else { emit({ from: JSON.stringify(value), to: JSON.stringify(mapping.rules), path: at }); return { ...holder, [key]: mapping.rules }; From 26e69704482616efac716ed0188960c2d652f68e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:42:33 +0000 Subject: [PATCH 07/12] fix(spec): the D3 filter-rule-array entry states the per-door refusal and the TODO listing Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../src/protocol.stored-migration.test.ts | 11 ++++++++++- ...data-source-and-object-block-filter-rule-array.ts | 12 ++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 5ecc923b5dc..8329120bb1a 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -767,7 +767,11 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, grid({ stage: 'open' }), { type: 'object-kanban', dataSource: { object: 'deal', filter: COMBINATOR }, properties: { objectName: 'deal' } }, ]); - const { engine, tables } = makeStubEngine([mixedPage, bindingMixed]); + // The third door: `defaultFilters` on the grid, the same open bag. + const defaultsMixed = pageRow('deal_grid', [ + { type: 'object-grid', properties: { objectName: 'deal', filter: { stage: 'open' }, defaultFilters: COMBINATOR } }, + ]); + const { engine, tables } = makeStubEngine([mixedPage, bindingMixed, defaultsMixed]); const protocol = new ObjectStackProtocolImplementation(engine); const report = await protocol.migrateStoredMetadata({ apply: true }); @@ -778,6 +782,11 @@ describe('migrateStoredMetadata — a site the chain leaves as stored is a TODO, const binding = report.rows.find((r) => r.name === 'deal_room')!; expect(binding.outcome).toBe('failed'); expect(binding.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[1].dataSource.filter']); + const defaults = report.rows.find((r) => r.name === 'deal_grid')!; + expect(defaults.outcome).toBe('rewritten'); + expect(defaults.todos.map((t) => t.path)).toEqual(['pages[0].regions[0].components[0].properties.defaultFilters']); + const storedDefaults = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_grid')!.metadata); + expect(storedDefaults.regions[0].components[0].properties.defaultFilters).toEqual(COMBINATOR); // The rewritten row persisted its lossless half; the combinator is byte-identical. const stored = JSON.parse(metaRows(tables).find((r) => r.name === 'deal_desk')!.metadata); diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index 328e00b975f..c1be9da9208 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -76,10 +76,14 @@ export const entry: SemanticMigration = { + 'their own rows in an in-memory data source that reads the record form and excludes ' + 'EVERY row for a rule array, so a rewrite there would empty the block. Such a row keeps loading unchanged ' + '(`applyConversionsToStoredItem` replays the chain without validating, by its own ' - + 'contract) and is refused at the `filter` door on its next save — for a combinator ' - + 'record, a refusal that names the combinator and says why no rule spells it. ' - + '`os migrate meta --stored` does not list these rows yet: a row the conversion leaves ' - + 'as stored reports there as already on protocol.', + + 'contract), and its `filter` door refuses the form: at `dataSource.filter` on the page\'s ' + + 'next save; at a block\'s `properties.filter` — like `properties.defaultFilters`, a key of ' + + 'the open `properties` bag — only as the component-props gate\'s advisory finding ' + + '(`os validate`), since a re-save through the metadata API is not refused there. For a ' + + 'combinator record that refusal names the combinator and says why no rule spells it. ' + + '`os migrate meta --stored` lists each filter left as stored as a TODO under its row, ' + + 'naming the block and what blocks the rewrite; a row whose only finding is such a TODO is ' + + 'reported `skipped`, and the run\'s exit code does not change for it.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' From bb0a8300d6948c53caabf299aa60353f7775a33b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:49:17 +0000 Subject: [PATCH 08/12] chore(spec): regenerate the migration registry mirror for the corrected D3 entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index daae679d9ff..63f749b0592 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8087,10 +8087,14 @@ const step18: MigrationStep = { + 'their own rows in an in-memory data source that reads the record form and excludes ' + 'EVERY row for a rule array, so a rewrite there would empty the block. Such a row keeps loading unchanged ' + '(`applyConversionsToStoredItem` replays the chain without validating, by its own ' - + 'contract) and is refused at the `filter` door on its next save — for a combinator ' - + 'record, a refusal that names the combinator and says why no rule spells it. ' - + '`os migrate meta --stored` does not list these rows yet: a row the conversion leaves ' - + 'as stored reports there as already on protocol.', + + 'contract), and its `filter` door refuses the form: at `dataSource.filter` on the page\'s ' + + 'next save; at a block\'s `properties.filter` — like `properties.defaultFilters`, a key of ' + + 'the open `properties` bag — only as the component-props gate\'s advisory finding ' + + '(`os validate`), since a re-save through the metadata API is not refused there. For a ' + + 'combinator record that refusal names the combinator and says why no rule spells it. ' + + '`os migrate meta --stored` lists each filter left as stored as a TODO under its row, ' + + 'naming the block and what blocks the rewrite; a row whose only finding is such a TODO is ' + + 'reported `skipped`, and the run\'s exit code does not change for it.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' From 847f1ab5793a2b30e031eb5214ea09249b90dd45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:58:40 +0000 Subject: [PATCH 09/12] fix(spec): the defaultFilters D3 entry states where its door refuses, not a re-save refusal Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .../18.object-grid-default-filters-rule-array.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts index 36413ae8e68..65d272dc522 100644 --- a/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.object-grid-default-filters-rule-array.ts @@ -56,9 +56,12 @@ export const entry: SemanticMigration = { + 'combinator, a null value, an operator the rule vocabulary does not spell, the bare ' + 'string or number this key also took, and any filter on a grid whose rows are inline ' + '(data with provider value, or staticData), for the reason its sibling gives — and ' - + 'RE-SAVING such a node is refused at the ' - + 'defaultFilters path, with the same conversion table the filter door gives, computed ' - + 'from the author\'s own keys. ADR-0049 / ADR-0087.', + + 'its door refuses such a value only as the component-props gate\'s advisory finding ' + + '(os validate, os build, os lint), since a re-save through the metadata API is not ' + + 'refused there: a record form with the message the filter door gives, a worked rewrite ' + + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' + + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' + + 'refusal. ADR-0049 / ADR-0087.', acceptanceCriteria: 'Every object-grid node in your pages either omits defaultFilters or carries a ' + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' From 755acf300f5ee20aae5fac13d4cf5be25fb574f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:58:54 +0000 Subject: [PATCH 10/12] chore(spec): regenerate the migration registry mirror for the corrected defaultFilters entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4b6fe59cc4d..83fe2c80dd2 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11443,9 +11443,12 @@ const step18: MigrationStep = { + 'combinator, a null value, an operator the rule vocabulary does not spell, the bare ' + 'string or number this key also took, and any filter on a grid whose rows are inline ' + '(data with provider value, or staticData), for the reason its sibling gives — and ' - + 'RE-SAVING such a node is refused at the ' - + 'defaultFilters path, with the same conversion table the filter door gives, computed ' - + 'from the author\'s own keys. ADR-0049 / ADR-0087.', + + 'its door refuses such a value only as the component-props gate\'s advisory finding ' + + '(os validate, os build, os lint), since a re-save through the metadata API is not ' + + 'refused there: a record form with the message the filter door gives, a worked rewrite ' + + 'computed from the author\'s own keys and a pointer to this entry\'s conversion table, ' + + 'and a bare string or number or an AST tuple array with the schema\'s plain type ' + + 'refusal. ADR-0049 / ADR-0087.', acceptanceCriteria: 'Every object-grid node in your pages either omits defaultFilters or carries a ' + 'ViewFilterRule array on it. The parse of an object-grid node whose defaultFilters is ' From 68294ccbc837671b36462eaba0be0a8bcc05decc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:25:18 +0000 Subject: [PATCH 11/12] fix(spec): name the unreported non-legacy value and all three props-gate commands in the D3 entry and the pending note Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- .changeset/17321-record-filter-d2-conversion.md | 9 +++++---- ...ata-source-and-object-block-filter-rule-array.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.changeset/17321-record-filter-d2-conversion.md b/.changeset/17321-record-filter-d2-conversion.md index 39e4549c99c..24cc0904278 100644 --- a/.changeset/17321-record-filter-d2-conversion.md +++ b/.changeset/17321-record-filter-d2-conversion.md @@ -48,7 +48,8 @@ excludes every row for a rule array, so a rewrite there would empty the block. T on a block that queries an object converts. Such a page keeps loading and rendering unchanged, and its `filter` door refuses the form: at `dataSource.filter` on the page's next save; at a block's `properties.filter` / `properties.defaultFilters` only as the component-props gate's -advisory finding (`os validate`) — a re-save through the metadata API is not refused there, -measured — and for a combinator record that refusal no longer renders the combinator as a field -(`{ field: '$or', … }`); it names the combinator and says why no rule spells it. -`os migrate meta --stored` lists each filter left as stored as a TODO under its row. +advisory finding (`os validate`, `os build`, `os lint`) — a re-save through the metadata API is +not refused there, measured — and for a combinator record that refusal no longer renders the +combinator as a field (`{ field: '$or', … }`); it names the combinator and says why no rule +spells it. +`os migrate meta --stored` lists each such filter left as stored as a TODO under its row. diff --git a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts index c1be9da9208..9215656b553 100644 --- a/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts +++ b/packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts @@ -79,11 +79,14 @@ export const entry: SemanticMigration = { + 'contract), and its `filter` door refuses the form: at `dataSource.filter` on the page\'s ' + 'next save; at a block\'s `properties.filter` — like `properties.defaultFilters`, a key of ' + 'the open `properties` bag — only as the component-props gate\'s advisory finding ' - + '(`os validate`), since a re-save through the metadata API is not refused there. For a ' - + 'combinator record that refusal names the combinator and says why no rule spells it. ' - + '`os migrate meta --stored` lists each filter left as stored as a TODO under its row, ' - + 'naming the block and what blocks the rewrite; a row whose only finding is such a TODO is ' - + 'reported `skipped`, and the run\'s exit code does not change for it.', + + '(os validate, os build, os lint), since a re-save through the metadata API is not ' + + 'refused there. For a combinator record that refusal names the combinator and says why no ' + + 'rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its ' + + 'row, naming the block and what blocks the rewrite (a value that is not a record or AST ' + + 'form at all — a bare string or number one of the former `z.unknown()` doors took — is ' + + 'neither converted nor reported, and a row carrying nothing else reads as already on ' + + 'protocol); a row whose only finding is such a TODO is reported `skipped`, and the run\'s ' + + 'exit code does not change for it.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule ' From bafa19790e13eeaaa33b63d69c69c54ccd7e2b01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 19:25:31 +0000 Subject: [PATCH 12/12] chore(spec): regenerate the migration registry mirror for the tightened D3 entry Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ Co-authored-by: Claude --- packages/spec/src/migrations/registry.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d1f7ec17f55..08f0e62ebe7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -8154,11 +8154,14 @@ const step18: MigrationStep = { + 'contract), and its `filter` door refuses the form: at `dataSource.filter` on the page\'s ' + 'next save; at a block\'s `properties.filter` — like `properties.defaultFilters`, a key of ' + 'the open `properties` bag — only as the component-props gate\'s advisory finding ' - + '(`os validate`), since a re-save through the metadata API is not refused there. For a ' - + 'combinator record that refusal names the combinator and says why no rule spells it. ' - + '`os migrate meta --stored` lists each filter left as stored as a TODO under its row, ' - + 'naming the block and what blocks the rewrite; a row whose only finding is such a TODO is ' - + 'reported `skipped`, and the run\'s exit code does not change for it.', + + '(os validate, os build, os lint), since a re-save through the metadata API is not ' + + 'refused there. For a combinator record that refusal names the combinator and says why no ' + + 'rule spells it. `os migrate meta --stored` lists each such filter as a TODO under its ' + + 'row, naming the block and what blocks the rewrite (a value that is not a record or AST ' + + 'form at all — a bare string or number one of the former `z.unknown()` doors took — is ' + + 'neither converted nor reported, and a row carrying nothing else reads as already on ' + + 'protocol); a row whose only finding is such a TODO is reported `skipped`, and the run\'s ' + + 'exit code does not change for it.', acceptanceCriteria: '`ElementDataSourceSchema.safeParse({ object, filter: [{ field: \'status\', operator: ' + '\'equals\', value: \'active\' }] })` succeeds and the parsed `filter` is the same rule '