From 6499cdf8d73b9d6856cf869a5c8cdf4d6d2967ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 10:44:46 +0000 Subject: [PATCH 1/6] test(app-shell): walk every declaring field against its own spec schema The declared-default ledger walked `field.path[1] === 'escalation'` only, so the four declarations outside that block claimed a default the installed spec applies none of and nothing reddened. Widen it to every node type in `FLOW_NODE_CONFIG`, each against its own spec schema, and enumerate the node types from the table's own source so a type added later cannot contribute a silent zero. Two registers record the live divergences instead of asserting them away: each entry re-measures the spec state it claims, and the register sets must match the measured divergence sets exactly, so an entry cannot outlive its divergence and a new divergence cannot hide behind one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...ow-node-config.spec-reconciliation.test.ts | 406 +++++++++++++++--- 1 file changed, 338 insertions(+), 68 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index f8745aef1a..286379fc0c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -30,12 +30,15 @@ * the sibling-block panels run against every spec version this repo supports. */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import * as Automation from '@objectstack/spec/automation'; // The Zod wrapper-key vocabulary — one list, read by the `.mjs` CI gates that // walk the same internals (objectui#6923, ruled 2026-08-31). import { ZOD_WRAPPER_KEYS } from '@object-ui/test-support'; -import { fieldsForNodeType, type FlowConfigField } from './flow-node-config'; +import { fieldsForNodeType, FLOW_NODE_TYPE_OPTIONS, type FlowConfigField } from './flow-node-config'; // Feature-detected exports — absent on a spec that predates framework#4278. // (Truthiness alone never resolves a lazySchema proxy.) @@ -284,7 +287,8 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) }); /** - * **Declared defaults ↔ spec defaults — the whole escalation block** (#6794, #6620). + * **Declared defaults ↔ spec defaults — EVERY declaring field** (#6794, #6620, + * objectui#9109). * * Everything above is a KEY-set ledger: it proves the form edits exactly the * keys the executor reads. The default a field DECLARES is the other axis, and @@ -292,113 +296,379 @@ describe('sibling-block forms ↔ FlowNodeSchema blocks (framework#4278 ratchet) * no `defaultValue` at all while the spec defaults the key to `true` (#6794), * then `escalation.enabled`, which declared `'false'` against a spec that had * flipped to `.default(true)` (#6620). Not cosmetic: `defaultValue` is what - * `controllerAdmits` resolves an unset controller against and what a `boolean` - * control seeds from, and it is what the ONLINE half of this form already - * carries (a published `configSchema` sends `default: true`, which - * `json-schema-to-fields` turns into `defaultValue: 'true'`) — so offline and + * `controllerAdmits` resolves an unset controller against, what a `boolean` + * control seeds from, and (since objectui#6830 arm A) what a `select` control + * states as its placeholder — and it is what the ONLINE half of this form + * already carries (a published `configSchema` sends `default: true`, which + * `json-schema-to-fields` turns into `defaultValue: 'true'`), so offline and * online rendered the same node from two different claims about the spec. * - * ⭐ **Why this is now block-wide, and why it is the point of #6620.** The - * previous revision scoped this to `notifySubmitter` ALONE and said so, to avoid - * arming an on-hold card from an unrelated PR. The cost of that scoping was the - * card's real defect: `escalation.enabled` had a "tripwire" in - * `flow-node-config.inactiveRetained.test.ts` that reads only the TABLE, so a - * spec bump could never redden anything — the divergence went live and stayed - * invisible until a human happened to re-read the spec. A one-directional check - * is not a check. This ledger walks whatever the installed spec materialises, so - * the NEXT flip, on any key in the block, reddens here on the bump itself. + * ⭐ **Why this is now table-wide, and why that is objectui#9109.** The previous + * revision walked `field.path[1] === 'escalation'` ALONE, and said so: the + * scoping was deliberate while objectui#6620 was on hold. objectui#6620 closed + * on 2026-09-08 and the reason is spent — but the cost of that scoping had + * already been paid, because FOUR declarations outside the escalation block + * were claiming a default the installed spec applies none of, and nothing + * reddened. A ledger that stops one block short is the same defect as no + * ledger, one block later. This walks every node type in `FLOW_NODE_CONFIG` + * against its OWN spec schema, so a declaration cannot sit outside it. * * The expected values are READ FROM THE INSTALLED SPEC, never spelled out here: * objectui is the consumer, and a literal restates exactly the claim that * drifts — it would pass just as happily on the next upstream flip. + * + * ⛔ **What this file does NOT decide.** Both registers below record live + * divergences rather than asserting them away, and neither register is a + * waiver: every entry re-measures the spec state it claims, and the register + * sets must match the measured divergence sets EXACTLY, so an entry cannot + * outlive the divergence and a new divergence cannot hide behind one. Which END + * of each divergence to move — delete the declaration, or back it upstream — is + * a product call this repo cannot make alone (objectui#9109 triage fence 2), and + * a test is the wrong place to make it. */ -describe('approval escalation: declared defaults ↔ ApprovalEscalationSchema (#6794, #6620)', () => { +describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, objectui#9109)', () => { // ⛔ The subpath is load bearing. `ApprovalEscalationSchema` is NOT on the // package root: `require('@objectstack/spec').ApprovalEscalationSchema` is // `undefined`, so a probe written that way dies with `Cannot read properties // of undefined` — a failure that reads as "the spec does not have it yet" and // sends the reader back to waiting. #6620 sat on hold behind exactly that // misreading. This file's own `import * as Automation` is the working spelling. - const EscalationSchema = spec.ApprovalEscalationSchema as - | { safeParse: (value: unknown) => { success: boolean; data?: Record } } - | undefined; + const flowNodeShape = objectShape(spec.FlowNodeSchema); + + /** + * The node-type universe, read from the TABLE'S OWN SOURCE rather than from a + * hand-kept list. + * + * `FLOW_NODE_CONFIG` is module-private and `fieldsForNodeType` answers `[]` + * for a type it has never heard of, so a type added to the table but missing + * from a hand-kept list contributes zero fields and the ledger reports a + * confident nothing — which is precisely how the four declarations this card + * is about stayed invisible. Enumerating from the source makes the TOTAL come + * from the same place the readings come from (AGENTS.md: "当一次扫描的「总体」 + * 和「逐项读取」来自不同来源时,对照必须取自总体那一侧"). + * + * Rooted at `import.meta.url`, never `process.cwd()` — the cwd differs between + * the repo-root and package-level invocations (objectui#7791/#7799). + */ + function nodeTypesFromSource(): string[] { + const here = path.dirname(fileURLToPath(import.meta.url)); + const src = fs.readFileSync(path.join(here, 'flow-node-config.ts'), 'utf8'); + const open = src.indexOf('const FLOW_NODE_CONFIG: Record = {'); + expect(open, 'FLOW_NODE_CONFIG must still be declared with this signature').toBeGreaterThan(-1); + const close = src.indexOf('\n};', open); + expect(close, 'FLOW_NODE_CONFIG must still close at column 0').toBeGreaterThan(open); + return [...src.slice(open, close).matchAll(/^ {2}([A-Za-z_][A-Za-z0-9_]*):/gm)].map((m) => m[1]!); + } - /** The block's only REQUIRED key. Supplied as input, so never a default. */ - const SUPPLIED: Record = { timeoutHours: 24 }; + const NODE_TYPES = nodeTypesFromSource(); + + it('the node-type enumeration is live, not an empty regex', () => { + // The positive control for the scan above. A regex that stopped matching + // returns `[]`, and every walk below would then pass over nothing — the + // vacuous-green shape this whole file exists to prevent. Aliases need no + // sweep of their own: `fieldsForNodeType` resolves every alias to one of + // these canonical tables, so walking the table keys walks every field. + expect(NODE_TYPES.length, 'FLOW_NODE_CONFIG declares node types').toBeGreaterThan(20); + expect(NODE_TYPES, 'and the picker types are among them').toEqual( + expect.arrayContaining([...FLOW_NODE_TYPE_OPTIONS]), + ); + expect(NODE_TYPES, 'including the off-picker tables a picker-only sweep would miss').toEqual( + expect.arrayContaining(['boundary_event', 'notify', 'legacy_action', 'join_gateway']), + ); + }); /** - * Every key the spec MATERIALISES from an omitted-key block, with its value — - * the runtime's own answer to "what does this node actually do", read fresh. + * One reconcilable region of the form: the fields under `prefix`, and the + * spec schema that decides what an omitted key there actually does. * - * Keys we supplied are subtracted: `timeoutHours` comes back only because we - * sent it, and counting it would demand the form declare a default for a - * required key that has none. + * `supplied` names the region's REQUIRED keys. They are sent as input so the + * parse can succeed, then subtracted from the materialised result — counting + * them would demand the form declare a default for a key that has none. */ - function specDefaults(): Record { - expect( - EscalationSchema, - '@objectstack/spec/automation must export ApprovalEscalationSchema', - ).toBeDefined(); - const parsed = EscalationSchema!.safeParse({ ...SUPPLIED }); - expect(parsed.success, 'a minimal escalation block must parse').toBe(true); - const out: Record = {}; - for (const [k, v] of Object.entries(parsed.data ?? {})) if (!(k in SUPPLIED)) out[k] = v; + interface DefaultScope { + readonly type: string; + readonly prefix: readonly string[]; + readonly schema: () => unknown; + readonly supplied: Record; + } + + const SCOPES: readonly DefaultScope[] = [ + { + type: 'approval', + prefix: ['config'], + schema: () => spec.ApprovalNodeConfigSchema, + supplied: { approvers: [{ type: 'user', value: 'u1' }] }, + }, + { + type: 'approval', + prefix: ['config', 'escalation'], + schema: () => spec.ApprovalEscalationSchema, + supplied: { timeoutHours: 24 }, + }, + { + type: 'http_request', + prefix: ['config'], + schema: () => spec.HttpConfigSchema, + supplied: { url: 'https://example.invalid/x' }, + }, + { type: 'screen', prefix: ['config'], schema: () => spec.ScreenConfigSchema, supplied: {} }, + { + type: 'wait', + prefix: ['waitEventConfig'], + schema: () => unwrapped(flowNodeShape?.waitEventConfig), + supplied: { eventType: 'timer' }, + }, + { + type: 'boundary_event', + prefix: ['boundaryConfig'], + schema: () => unwrapped(flowNodeShape?.boundaryConfig), + supplied: { attachedToNodeId: 'n1', eventType: 'error' }, + }, + ]; + + const scopeId = (type: string, prefix: readonly string[]) => `${type}:${prefix.join('.')}`; + + /** Every field in the table that DECLARES a default, with the scope it sits in. */ + function declaringFields(): Array<{ scope: string; key: string; field: FlowConfigField }> { + const out: Array<{ scope: string; key: string; field: FlowConfigField }> = []; + for (const type of NODE_TYPES) { + for (const field of fieldsForNodeType(type)) { + if (field.defaultValue === undefined) continue; + out.push({ + scope: scopeId(type, field.path.slice(0, -1)), + key: field.path[field.path.length - 1]!, + field, + }); + } + } return out; } - /** The approval form's escalation fields, keyed by the spec key each edits. */ - function escalationFields(): Map { + /** The form fields inside one scope, keyed by the spec key each edits. */ + function fieldsInScope(scope: DefaultScope): Map { const out = new Map(); - for (const f of fieldsForNodeType('approval')) { - if (f.path[0] === 'config' && f.path[1] === 'escalation' && f.path[2]) out.set(f.path[2], f); + for (const f of fieldsForNodeType(scope.type)) { + if (f.path.length !== scope.prefix.length + 1) continue; + if (!scope.prefix.every((seg, i) => f.path[i] === seg)) continue; + out.set(f.path[f.path.length - 1]!, f); } return out; } + /** + * Every key the spec MATERIALISES from an omitted-key region, with its value — + * the runtime's own answer to "what does this node actually do", read fresh. + */ + function specDefaults(scope: DefaultScope): Record { + const schema = scope.schema() as + | { safeParse: (v: unknown) => { success: boolean; data?: Record } } + | undefined; + expect( + schema?.safeParse, + `@objectstack/spec/automation must expose a parseable schema for ${scopeId(scope.type, scope.prefix)}`, + ).toBeTypeOf('function'); + const parsed = schema!.safeParse({ ...scope.supplied }); + expect(parsed.success, `a minimal ${scopeId(scope.type, scope.prefix)} region must parse`).toBe(true); + const out: Record = {}; + for (const [k, v] of Object.entries(parsed.data ?? {})) if (!(k in scope.supplied)) out[k] = v; + return out; + } + + /** + * ⛔ **The two registers — live divergences, recorded, not waived.** + * + * ⭐ The four unbacked declarations are NOT one class, and a register that + * recorded them as one would be wrong about half of them (objectui#9109 + * measurement comment, 2026-09-11). Each entry therefore carries the spec + * state it claims, and `state` is RE-MEASURED below — an entry whose claim + * stops being true reddens as loudly as an unregistered divergence. + * + * - `required-no-default` — the spec key is REQUIRED. There is no runtime + * default to state: an omitted key does not behave as the declared value, + * it FAILS TO PARSE. "Unset behaves as X" is not merely unbacked here, it + * is the wrong SHAPE of statement, and both of these already have on-screen + * effect — each gates siblings through `controllerAdmits` on a node that + * stored no value at all. + * - `optional-no-default` — the spec key is OPTIONAL and the installed Zod + * materialises nothing for it. ⚠️ That is a statement about SCHEMA + * DEFAULTING and nothing else: the flow EXECUTOR lives in `objectstack`, + * `@objectstack/spec` is only the parse contract, so whether the engine + * applies `GET` / create-mode when it runs the node is **NOT MEASURED** + * here — ⛔ not "measured false". If it does, the fix is upstream and this + * register entry is how the two ends stay connected. + */ + const UNBACKED_REGISTER: ReadonlyArray<{ + region: string; + key: string; + state: 'required-no-default' | 'optional-no-default'; + }> = [ + { region: 'wait:waitEventConfig', key: 'eventType', state: 'required-no-default' }, + { region: 'boundary_event:boundaryConfig', key: 'eventType', state: 'required-no-default' }, + { region: 'http_request:config', key: 'method', state: 'optional-no-default' }, + { region: 'screen:config', key: 'mode', state: 'optional-no-default' }, + ]; + + /** + * The other direction's register: the spec APPLIES a default and the form + * field for that key declares none — the #6794 shape exactly, found by this + * widening in two places the escalation-only walk could never reach. + * Filed separately; ⛔ not fixed here, because adding a declaration moves the + * ten-field acceptance pin objectui#6830 deliberately placed in + * `FlowNodeInspector.declaredDefault.test.tsx` and creates an on-screen claim, + * neither of which is this card's to decide. + */ + const UNDECLARED_REGISTER: ReadonlyArray<{ region: string; key: string }> = [ + { region: 'approval:config', key: 'lockRecord' }, + { region: 'boundary_event:boundaryConfig', key: 'interrupting' }, + ]; + + // ⚠️ `region`, not `scope`: vitest reads `$a.$b` in an `it.each` title as the + // PATH `a.$b`, so a dotted pair of placeholders renders `undefined` and every + // register row gets the same nameless title. The separator below keeps both + // halves addressable. + const rowId = (r: { region: string; key: string }) => `${r.region}.${r.key}`; + it('the gate `enabled` is inside the ledger — and the ledger is not empty', () => { - // THE VACUITY GUARD, and the reason it names a key. Both assertions below - // iterate `specDefaults()`; a spec that stopped materialising anything would - // make each of them pass over an empty collection, which is the shape #6620's - // old tripwire failed in. This row fails instead — and it names `enabled` - // because that is the key the card was about, so the ledger's coverage of it - // is visible in a test name rather than only inferable from a loop. - const defaults = specDefaults(); + // THE VACUITY GUARD, and the reason it names a key. Every walk below + // iterates materialised defaults; a spec that stopped materialising + // anything would make each of them pass over an empty collection, which is + // the shape #6620's old tripwire failed in. This row fails instead — and it + // names `enabled` because that is the key that card was about, so the + // ledger's coverage of it is visible in a test name rather than only + // inferable from a loop. + const escalation = SCOPES.find((s) => scopeId(s.type, s.prefix) === 'approval:config.escalation')!; + const defaults = specDefaults(escalation); expect(Object.keys(defaults).length, 'the spec materialises at least one default here').toBeGreaterThan(0); expect(typeof defaults.enabled, 'the spec materialises `enabled` from an omitted key').toBe('boolean'); + + const everything = SCOPES.flatMap((s) => Object.keys(specDefaults(s))); + expect(everything.length, 'and the table-wide walk materialises defaults in more than one scope').toBeGreaterThan( + Object.keys(defaults).length, + ); + }); + + it('every declaring field in the whole table sits inside a scope', () => { + // ⭐ THE RATCHET, and the whole point of objectui#9109. The old walk was + // `field.path[1] === 'escalation'`, so four declarations sat outside it and + // nothing reddened. A declaration added anywhere the `SCOPES` table does not + // cover now fails HERE, naming itself — it can no longer go unchecked by + // being somewhere nobody looked. + const uncovered = declaringFields() + .filter((d) => !SCOPES.some((s) => scopeId(s.type, s.prefix) === d.scope)) + .map((d) => `${d.scope}.${d.key} declares ${JSON.stringify(d.field.defaultValue)} with no spec scope to check it against`); + expect(uncovered, 'add a DefaultScope for this region, with the spec schema that governs it').toEqual([]); + // …and the scopes are not all empty, which is the way the line above lies. + expect(declaringFields().length, 'the table still declares defaults at all').toBeGreaterThan(5); }); it('every default the spec applies is declared by the form, with the same value', () => { - const fields = escalationFields(); const mismatches: string[] = []; - for (const [key, value] of Object.entries(specDefaults())) { - const field = fields.get(key); - if (!field) { - mismatches.push(`${key}: the spec defaults it, the form offers no field for it`); - continue; - } - // Defaults are strings in this table — booleans spelled 'true' / 'false', - // the spelling `controllerAdmits` compares a controller against. - if (field.defaultValue !== String(value)) { - mismatches.push( - `${key}: the form declares ${JSON.stringify(field.defaultValue)}, the spec applies ${JSON.stringify(String(value))}`, - ); + const noField: string[] = []; + const undeclared: string[] = []; + for (const scope of SCOPES) { + const id = scopeId(scope.type, scope.prefix); + const fields = fieldsInScope(scope); + for (const [key, value] of Object.entries(specDefaults(scope))) { + const field = fields.get(key); + if (!field) { + noField.push(`${id}.${key}: the spec defaults it, the form offers no field for it`); + } else if (field.defaultValue === undefined) { + undeclared.push(`${id}.${key}`); + } else if (field.defaultValue !== String(value)) { + // Defaults are strings in this table — booleans spelled 'true' / + // 'false', the spelling `controllerAdmits` compares a controller + // against. + mismatches.push( + `${id}.${key}: the form declares ${JSON.stringify(field.defaultValue)}, the spec applies ${JSON.stringify(String(value))}`, + ); + } } } expect( mismatches, 'the hand-written table must state what an omitted key actually does at runtime', ).toEqual([]); + expect( + noField, + 'a spec default with no field at all is a key-set hole, never a registerable divergence', + ).toEqual([]); + expect( + undeclared.sort(), + 'the spec applies a default the form states nowhere — register it or declare it', + ).toEqual(UNDECLARED_REGISTER.map(rowId).sort()); }); it('and the form declares no default the spec does not apply', () => { - // The other direction, and not symmetric decoration: a `defaultValue` with no - // spec counterpart is a claim about the contract with nothing behind it, and - // it is ACTED ON — it resolves a `showWhen` controller and seeds a boolean - // control off a value the runtime never applies. - const defaults = specDefaults(); - const invented = [...escalationFields()] - .filter(([key, f]) => f.defaultValue !== undefined && !(key in defaults)) - .map(([key, f]) => `${key}: the form declares ${JSON.stringify(f.defaultValue)}, the spec applies none`); - expect(invented, 'a declared default with no spec counterpart').toEqual([]); + // The other direction, and not symmetric decoration: a `defaultValue` with + // no spec counterpart is a claim about the contract with nothing behind it, + // and it is ACTED ON — it resolves a `showWhen` controller, seeds a boolean + // control, and states itself as a select trigger's placeholder, off a value + // the runtime never applies. + const byScope = new Map(SCOPES.map((s) => [scopeId(s.type, s.prefix), specDefaults(s)])); + const invented = declaringFields() + .filter((d) => byScope.has(d.scope) && !(d.key in byScope.get(d.scope)!)) + .map((d) => rowId({ region: d.scope, key: d.key })); + expect( + invented.sort(), + 'a declared default with no spec counterpart — register it or remove it', + ).toEqual(UNBACKED_REGISTER.map(rowId).sort()); }); + + it.each(UNBACKED_REGISTER)( + 'register row $region · $key still measures as $state', + ({ region: id, key, state }) => { + // ⛔ A register entry is an ASSERTION, never a waiver: it re-measures the + // spec state it claims. An entry that outlives its divergence (the key + // gained a `.default()`, or turned optional) reddens here, which is what + // keeps the register shrinking rather than accumulating. + const scope = SCOPES.find((s) => scopeId(s.type, s.prefix) === id)!; + const withoutKey = Object.fromEntries( + Object.entries(scope.supplied).filter(([k]) => k !== key), + ); + const schema = scope.schema() as { + safeParse: (v: unknown) => { + success: boolean; + data?: Record; + error?: { issues: Array<{ path: PropertyKey[] }> }; + }; + }; + const parsed = schema.safeParse(withoutKey); + + if (state === 'required-no-default') { + // The sharper of the two, and it needs no executor: an omitted key does + // not behave as the declared value, it is REFUSED at the door. + expect(parsed.success, `${id}.${key}: a REQUIRED key must refuse an omitted value`).toBe(false); + expect( + parsed.error?.issues.map((i) => i.path.join('.')), + `${id}.${key}: and the refusal must name this key`, + ).toContain(key); + } else { + // Optional, and the installed Zod materialises nothing. ⚠️ Evidence + // about SCHEMA DEFAULTING only — the executor is in `objectstack` and + // is NOT MEASURED by this repo. + expect(parsed.success, `${id}.${key}: an OPTIONAL key must parse when omitted`).toBe(true); + expect( + parsed.data && key in parsed.data, + `${id}.${key}: and the spec must materialise nothing for it`, + ).toBe(false); + } + }, + ); + + it.each(UNDECLARED_REGISTER)( + 'register row $region · $key still measures as spec-applies-form-declares-none', + ({ region: id, key }) => { + const scope = SCOPES.find((s) => scopeId(s.type, s.prefix) === id)!; + expect( + key in specDefaults(scope), + `${id}.${key}: the spec must still materialise this key`, + ).toBe(true); + const field = fieldsInScope(scope).get(key); + expect(field, `${id}.${key}: the form must still offer a field for it`).toBeDefined(); + expect( + field!.defaultValue, + `${id}.${key}: declare it (and drop this row) rather than leaving the register stale`, + ).toBeUndefined(); + }, + ); }); From 996ceea8a6cf1f85168f0b76d2f54bff24f2f3b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 11:10:03 +0000 Subject: [PATCH 2/6] chore(changeset): declare the ledger widening as releasing nothing `@object-ui/app-shell` publishes `dist` and `src/styles.css`; the only file this change touches is a `*.test.ts` under `src/views/`, and none of the eight publish-contract fields moved. Empty frontmatter is the explicit exemption the presence gate prescribes for exactly this shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/issue-9109-widen-default-ledger.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/issue-9109-widen-default-ledger.md diff --git a/.changeset/issue-9109-widen-default-ledger.md b/.changeset/issue-9109-widen-default-ledger.md new file mode 100644 index 0000000000..bd57128996 --- /dev/null +++ b/.changeset/issue-9109-widen-default-ledger.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only: the flow-node designer's declared-default ledger now walks every declaring field against its own per-node-type spec schema instead of stopping at the approval-escalation block. No published source file moves — `@object-ui/app-shell` publishes `dist` and `src/styles.css`, and the only file touched is a `*.test.ts` under `src/views/`. Recording, not releasing (objectui#9109). From b9781732a73a474f8c4c8b4ea6146b9e0fce6396 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 11:26:35 +0000 Subject: [PATCH 3/6] docs(app-shell): the defaultValue doc comment no longer describes a ledger that stopped short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment told the next author that the reconciliation ledger "does NOT yet cover the other declaring fields ... so a new declaration outside that block is currently unchecked". Widening the ledger made that false, and a stale warning that understates a guard is worse than none — it invites the exact drift the widening closed. Comment only: no declaration, option list, control or rendered value moves, and `git diff` touches zero `defaultValue` lines. Which END of the recorded divergences to move stays a human's call, and the new text says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .changeset/issue-9109-widen-default-ledger.md | 2 +- .../inspectors/flow-node-config.ts | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/issue-9109-widen-default-ledger.md b/.changeset/issue-9109-widen-default-ledger.md index bd57128996..5af969f576 100644 --- a/.changeset/issue-9109-widen-default-ledger.md +++ b/.changeset/issue-9109-widen-default-ledger.md @@ -1,4 +1,4 @@ --- --- -Test-only: the flow-node designer's declared-default ledger now walks every declaring field against its own per-node-type spec schema instead of stopping at the approval-escalation block. No published source file moves — `@object-ui/app-shell` publishes `dist` and `src/styles.css`, and the only file touched is a `*.test.ts` under `src/views/`. Recording, not releasing (objectui#9109). +Test-only: the flow-node designer's declared-default ledger now walks every declaring field against its own per-node-type spec schema instead of stopping at the approval-escalation block. Two files move — the reconciliation test itself, and one doc comment in `flow-node-config.ts` that described the ledger's old narrow scope and would otherwise have been left stating something untrue. No declaration, option list, control or rendered value changes; `@object-ui/app-shell` publishes `dist` and `src/styles.css`, and none of the eight publish-contract fields moved. Recording, not releasing (objectui#9109). diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index 9aec028b1a..6dfbb5bac8 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -249,12 +249,20 @@ export interface FlowConfigField { * is cheap, a first write site is not this file's to add. * * ⚠️ A declaration here is a claim about the installed spec and is acted on - * as one; `flow-node-config.spec-reconciliation.test.ts` reconciles the - * approval-escalation block against `ApprovalEscalationSchema` so a drift - * there reddens on the bump. That ledger does NOT yet cover the other - * declaring fields (objectui#6830 measured four of them declaring a default - * the installed spec applies none of), so a new declaration outside that - * block is currently unchecked — derive it from the spec, never from taste. + * as one; `flow-node-config.spec-reconciliation.test.ts` reconciles EVERY + * declaring field against its own per-node-type spec schema, so a drift + * reddens on the bump. objectui#9109 widened that ledger from the + * approval-escalation block, which it used to walk alone — the four + * declarations the installed spec applies none of had been sitting outside + * it, unchecked, for exactly that reason. A declaration added in a region + * the ledger has no spec schema for now fails there by name rather than + * going unnoticed. + * + * What the ledger cannot decide is which END of a divergence to move: it + * RECORDS the unbacked declarations (and the reverse case, a spec default + * this table states nowhere) in registers that re-measure themselves, and + * leaves the choice to a human. ⇒ derive a new value from the spec, never + * from taste. */ defaultValue?: string; /** From 7f3b4e73e53e327fe54d56d2a24b759ab373ded0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 15:06:39 +0000 Subject: [PATCH 4/6] test(app-shell): retire the register rows objectui#9339 repaid, without leaving a vacuous green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `approval:config.lockRecord` and `boundary_event:boundaryConfig.interrupting` were carried as UNDECLARED register rows: the spec materialises a default and the form declared none. objectui#9339 declared both at the form, so on current `main` the rows assert something false — each row re-measures its own claim, so all three of the assertions that read them reddened as designed. Removing them empties the register, and that is where the shape mattered. The re-measurement was `it.each(UNDECLARED_REGISTER)`: one case per row, therefore NO case at all once the rows go, running nothing and still reporting green — a reader would see a re-measured register where nothing was measured. It now walks `SCOPES` instead, whose population this file already guards, so every region answers for itself and a region carrying no row answers positively: nothing here is left undeclared. The assertion can fail in both states, and a row that outlives its divergence still reddens in its region's case. The register's doc comment points at the declaration for what it holds rather than restating a count (AGENTS.md #9), and the stale rationale for not declaring these two — which named a fixed field count in prose — goes with the rows it justified. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- ...ow-node-config.spec-reconciliation.test.ts | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 286379fc0c..7f5a6efa04 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -511,16 +511,22 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object /** * The other direction's register: the spec APPLIES a default and the form * field for that key declares none — the #6794 shape exactly, found by this - * widening in two places the escalation-only walk could never reach. - * Filed separately; ⛔ not fixed here, because adding a declaration moves the - * ten-field acceptance pin objectui#6830 deliberately placed in - * `FlowNodeInspector.declaredDefault.test.tsx` and creates an on-screen claim, - * neither of which is this card's to decide. + * widening in places the escalation-only walk could never reach. + * + * ⭐ A row here is an EXCEPTION, and an exception is meant to be repaid. + * The two this register opened with — `approval:config.lockRecord` and + * `boundary_event:boundaryConfig.interrupting` — were repaid at the form by + * objectui#9339, which declared both from the installed spec. They are + * therefore RETIRED, not carried: a row that outlives its divergence asserts + * something false about the contract, which is worse than no row at all. + * + * ⛔ Read what this register holds from the declaration below, never from a + * count restated in prose (AGENTS.md #9) — and never from the SHAPE of the + * test that walks it. That test iterates `SCOPES`, not this array, exactly so + * that an empty register still states its claim region by region instead of + * contributing no case and reporting green. */ - const UNDECLARED_REGISTER: ReadonlyArray<{ region: string; key: string }> = [ - { region: 'approval:config', key: 'lockRecord' }, - { region: 'boundary_event:boundaryConfig', key: 'interrupting' }, - ]; + const UNDECLARED_REGISTER: ReadonlyArray<{ region: string; key: string }> = []; // ⚠️ `region`, not `scope`: vitest reads `$a.$b` in an `it.each` title as the // PATH `a.$b`, so a dotted pair of placeholders renders `undefined` and every @@ -655,20 +661,39 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object }, ); - it.each(UNDECLARED_REGISTER)( - 'register row $region · $key still measures as spec-applies-form-declares-none', - ({ region: id, key }) => { - const scope = SCOPES.find((s) => scopeId(s.type, s.prefix) === id)!; - expect( - key in specDefaults(scope), - `${id}.${key}: the spec must still materialise this key`, - ).toBe(true); - const field = fieldsInScope(scope).get(key); - expect(field, `${id}.${key}: the form must still offer a field for it`).toBeDefined(); + // ⛔ THE REGISTER'S RE-MEASUREMENT, and it walks `SCOPES` rather than + // `UNDECLARED_REGISTER` on purpose. Written as `it.each(UNDECLARED_REGISTER)` + // it contributed one case per row — so a register that empties contributes NO + // case, runs nothing, and still reports green, leaving a reader to see a + // re-measured register where nothing whatever was measured. The scope table is + // this file's fixed population (the vacuity guard above reddens if it stops + // materialising defaults), so every region answers for itself, and a region + // carrying no row answers with the positive statement: nothing here is left + // undeclared. A row is still an ASSERTION, never a waiver — one whose key has + // since been declared drops out of its region's walk and reddens here. + // (A row naming a region outside `SCOPES` would be unreachable from this walk; + // the table-wide equality above compares the register whole and fails on it.) + it.each(SCOPES.map((s) => ({ scope: s, region: scopeId(s.type, s.prefix) })))( + 'region $region leaves no spec default undeclared beyond its register rows', + ({ scope, region }) => { + const fields = fieldsInScope(scope); + const undeclared = Object.keys(specDefaults(scope)) + .filter((key) => { + // A key the form offers no field for at all is a key-set hole, + // asserted on its own above; this register is only ever about a key + // the form OFFERS and then declares nothing for. + const field = fields.get(key); + return field !== undefined && field.defaultValue === undefined; + }) + .sort(); expect( - field!.defaultValue, - `${id}.${key}: declare it (and drop this row) rather than leaving the register stale`, - ).toBeUndefined(); + undeclared, + `${region}: declare it on the form field, or carry it as a register row with the reason`, + ).toEqual( + UNDECLARED_REGISTER.filter((r) => r.region === region) + .map((r) => r.key) + .sort(), + ); }, ); }); From 3e363c672a63a6c3fa811314c74699e70d798237 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 15:53:46 +0000 Subject: [PATCH 5/6] test(app-shell): bring the end node's config into the ledger, now that it declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#9337 turned `end.config.outcome` into a spec-derived select declaring `'completed'`. That made it a declaring field, and a declaring field sitting outside every scope is exactly what this file's completeness ratchet refuses — so the ratchet failed by name on it the moment `main` was merged in, which is the ratchet doing its job on a collision between two independently green branches rather than a regression in either. `EndConfigSchema` is the schema that governs the region, and it materialises `outcome: 'completed'` from an omitted key — the same value the form now declares. So the region reconciles: the walk closes the ratchet without opening an UNDECLARED row, without a new UNBACKED row, and without moving the exact-count acceptance pin in `FlowNodeInspector.declaredDefault.test.tsx`, which this commit does not touch. The ledger can now check that declaration instead of stepping around it, which is the card's whole subject — `end` had been fenced out only on the ground that it declared nothing, and that ground expired upstream. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../inspectors/flow-node-config.spec-reconciliation.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 7f5a6efa04..39f4af72a4 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -424,6 +424,12 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object schema: () => unwrapped(flowNodeShape?.boundaryConfig), supplied: { attachedToNodeId: 'n1', eventType: 'error' }, }, + // objectui#9337 turned `end.config.outcome` into a spec-derived select that + // DECLARES `'completed'`, which made it a declaring field — and a declaring + // field sitting outside every scope is precisely what the ratchet above + // refuses. `EndConfigSchema` is the schema that governs the region, so the + // ledger can now check the declaration instead of stepping around it. + { type: 'end', prefix: ['config'], schema: () => spec.EndConfigSchema, supplied: {} }, ]; const scopeId = (type: string, prefix: readonly string[]) => `${type}:${prefix.join('.')}`; From 6512d2d0f58d0cf624b0660b273e32aa95a09b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 04:23:55 +0000 Subject: [PATCH 6/6] test(app-shell): qualify the two retained case names the rewrite outgrew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cases kept their names while this branch rewrote their assertions from an absolute emptiness check to a comparison against a register, so each name states something its own assertion no longer asserts. `and the form declares no default the spec does not apply` is the blocking one. Its assertion now compares the measured set against UNBACKED_REGISTER, which carries exactly the declarations this card was filed about, so the case printed that sentence green while asserting its negation — a reader of `--reporter=verbose` was told the opposite of the card's own finding. `every default the spec applies is declared by the form, with the same value` is the same shape one direction over. It reads true today only because UNDECLARED_REGISTER is empty; the first row registered there would make the name false, with nothing to catch it. Both now carry the qualification the sibling case added in this same branch already used — `region ... leaves no spec default undeclared beyond its register rows` — so the two directions read consistently. Names only. With comments and string literals stripped, the executable lines of this file are byte-identical to their previous revision. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../inspectors/flow-node-config.spec-reconciliation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts index 39f4af72a4..9b32ca2db0 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.spec-reconciliation.test.ts @@ -573,7 +573,7 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object expect(declaringFields().length, 'the table still declares defaults at all').toBeGreaterThan(5); }); - it('every default the spec applies is declared by the form, with the same value', () => { + it('every default the spec applies is declared by the form, with the same value, beyond its register rows', () => { const mismatches: string[] = []; const noField: string[] = []; const undeclared: string[] = []; @@ -610,7 +610,7 @@ describe('declared defaults ↔ per-node-type spec schemas (#6794, #6620, object ).toEqual(UNDECLARED_REGISTER.map(rowId).sort()); }); - it('and the form declares no default the spec does not apply', () => { + it('and the form declares no default the spec does not apply, beyond its register rows', () => { // The other direction, and not symmetric decoration: a `defaultValue` with // no spec counterpart is a claim about the contract with nothing behind it, // and it is ACTED ON — it resolves a `showWhen` controller, seeds a boolean