// Read fields from actual paired frames; literal old-field lists expire when the rolling release baseline gains a field. export type PublishedFieldSkew = { /** Names only the newer side publishes — additive, and safe under Rule 1. */ added: string[] /** Names the older side still publishes and the newer side dropped — a break. */ removed: string[] } export function publishedFieldNames(payload: Record): string[] { return Object.keys(payload).sort() } export function comparePublishedFields(args: { older: string[] newer: string[] }): PublishedFieldSkew { const older = new Set(args.older) const newer = new Set(args.newer) return { added: [...newer].filter((name) => !older.has(name)).sort(), removed: [...older].filter((name) => !newer.has(name)).sort() } } /** Compare corresponding occurrences without letting sibling frames hide a removal. */ export function comparePublishedFieldOccurrences(args: { older: readonly Record[] newer: readonly Record[] }): PublishedFieldSkew[] { if (args.older.length !== args.newer.length) { throw new Error( `Published frame occurrence count differs: older ${args.older.length}, newer ${args.newer.length}` ) } return args.older.map((older, index) => { const newer = args.newer[index] if (!newer) { throw new Error(`Missing newer published frame occurrence ${index + 1}`) } return comparePublishedFields({ older: publishedFieldNames(older), newer: publishedFieldNames(newer) }) }) }