From 655e5bfe68ed696b007f1e0efa941d8e932f42a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 02:06:52 +0000 Subject: [PATCH 1/2] docs(spec): name the node slot in the structural-condition ruling and its ADR-0087 entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank structural condition is refused at all three doors since #17322 and #17495; two places in `packages/spec` still described the world before that. `structuralConditionRefusal`'s docblock admitted a whitespace-only string on #15662's ground that it "is consistent on both sides and is ruled correct, not a defect". #15807 removed that ground at the edge door and #17322 ruled on the disagreement. The admission itself is unchanged and still correct — this function answers the SHAPE question and the blank is refused beside it by the imported evaluated-slot rule — so what changes is the recorded reason, plus a section recording, without answering, the open question at the sibling ledger `predicate` slots. The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` named only `edges[].condition` in its `surface` and `acceptanceCriteria`, so a consumer replaying the chain was told to sweep the edge key alone and a deployment carrying a blank `config.condition` was never told to look. Both fields now name both structural slots, with the node key's own locator phrase. `registry.ts` is regenerated by `pnpm --filter @objectstack/spec gen:migration-registry`. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../17493-node-door-refusal-residues.md | 39 +++++++++++++++ .../automation/flow-node-expression-paths.ts | 45 +++++++++++++++-- ...ondition-evaluated-slot-source-required.ts | 49 ++++++++++++------- packages/spec/src/migrations/registry.ts | 49 ++++++++++++------- 4 files changed, 140 insertions(+), 42 deletions(-) create mode 100644 .changeset/17493-node-door-refusal-residues.md diff --git a/.changeset/17493-node-door-refusal-residues.md b/.changeset/17493-node-door-refusal-residues.md new file mode 100644 index 0000000000..e75807c61c --- /dev/null +++ b/.changeset/17493-node-door-refusal-residues.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): the structural-condition ruling and the ADR-0087 entry both name the NODE slot (#17493) + +Two places in `packages/spec` still described the world as it was before the +blank structural condition became a defect. Neither changes behaviour: this is +the notification half of a refusal that has already shipped. + +**The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` +named only the edge key.** Its `surface` and `acceptanceCriteria` told a +consumer replaying the chain to sweep `edges[].condition` and nothing else — +so a deployment carrying a blank `config.condition` on a flow node was never +told to look, even though `AutomationEngine.registerFlow` refuses it since +#17322 and `objectstack validate` since #17495. Both fields now name both +structural slots, the node key's own locator +(the phrase the structural pass builds, e.g. `node 'gate' (start) condition`) is +stated beside the edge's `flows.N.edges.N.condition`, and the sweep carries the +warning that removing a `condition` from a `start` node opens the trigger gate +rather than preserving it. The entry's `id`, `replacement` and `reason` are +untouched, and no new entry is added: this is one decision reaching its second +slot, not a second decision. + +**`structuralConditionRefusal`'s docblock stated a ruling that had become +false.** It admitted a whitespace-only string on the ground that such a +condition "is consistent on both sides and is ruled correct, not a defect" — +the ground #15807 removed at the edge door and #17322 ruled on. The admission +itself is unchanged and still correct, because this function answers the SHAPE +question only and the blank is refused beside it by the imported +evaluated-slot rule; what the docblock now records is which card removed the +ground, which door each refusal lives at, and why the two refusals are kept +distinct. + +It also records, without answering, the question one slot over: the ledger +`predicate` slots (`config.conditions[].expression`, +`screen.fields[].visibleWhen`) still admit a whitespace-only string, pinned as +correct by #15572 on the same ground. Narrowing them re-judges that pin and +moves a published accept-set, so it is a ruling and stays open on #17493. diff --git a/packages/spec/src/automation/flow-node-expression-paths.ts b/packages/spec/src/automation/flow-node-expression-paths.ts index 803fb46195..8ab4f95155 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.ts @@ -421,10 +421,30 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL = * * `undefined` — admitted — for: * - * - every **string**, including a whitespace-only one. What a non-empty string - * *says* stays `validateExpression('predicate', …)`'s verdict, and a - * whitespace-only condition meaning `false` is consistent on both sides and - * is ruled correct, not a defect. + * - every **string**, including a whitespace-only one — on the SHAPE question, + * which is the only question this function answers. What a non-empty string + * *says* stays `validateExpression('predicate', …)`'s verdict. + * + * ⚠️ The whitespace-only string is still admitted here, but NOT for the + * reason #15662 gave. That reason was that such a condition, meaning `false`, + * "is consistent on both sides and is ruled correct, not a defect" — and + * #15807 removed the ground under it, by making `FlowEdgeSchema.condition` + * compose `EvaluatedExpressionInputSchema`, which refuses a blank `source` at + * `FlowSchema.parse`. #17322 then ruled on the disagreement that left + * (一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑) and rebound the node + * door at `AutomationEngine.registerFlow`; #17495 followed at + * `objectstack validate`. A blank structural condition is a defect today, + * refused at all three doors. + * + * It is refused there by the EVALUATED-SLOT rule, not by this one. Both + * consumers ask `EvaluatedExpressionInputSchema` — the edge door's own + * schema, imported rather than restated — in a second gate sitting behind + * this shape refusal and in front of the CEL pass, answering + * `EVALUATED_EXPRESSION_SOURCE_REQUIRED` and not + * {@link STRUCTURAL_CONDITION_SHAPE_REFUSAL}. Keeping the two distinct is + * deliberate: a string IS a well-shaped structural condition, and a second + * hand-written notion of "blank" per door is exactly the drift #15662 built + * this one shared refusal to prevent. ⛔ Do not move the blank rule in here. * - absent / `null`. "Not authored" is not a malformed predicate; both callers * already return early on it, and this agrees rather than disagreeing. * - an **expression envelope the engine can evaluate**: an object carrying a @@ -451,6 +471,23 @@ export const STRUCTURAL_CONDITION_SHAPE_REFUSAL = * `EvaluatedExpressionSchema` is the one place to relax, and this clause * follows it. * + * ## The sibling `predicate` slots — an OPEN question, not answered here + * + * The blank rule reached the two STRUCTURAL slots only. The ledger `predicate` + * slots — `config.conditions[].expression`, a `decision` node's branch list, and + * `screen.fields[].visibleWhen` — are judged by {@link predicateSlotRefusal}, + * not by this function, and they still ADMIT a whitespace-only string: + * registration takes it and `evaluateCondition` answers `false`, the same silent + * dead branch #17322 closed one slot over. Recorded here rather than fixed, + * because it is a RULING and not a refactor: #15572 pinned that admission as + * correct on the very ground #15807 removed — that the blank is treated the same + * way on both sides — so narrowing those slots re-judges a pin and moves a + * published accept-set. #17493 carries the question (does the ledger predicate + * slot follow the structural one?) and it is open at the time of writing. ⛔ Do + * not answer it by widening this refusal: those slots do not pass through this + * door, and a second notion of "blank" is what the shared refusal exists to + * prevent. + * * ## What it refuses, and what that was doing before * * A number, a boolean, an array, or an object carrying no string `source` — diff --git a/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts b/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts index af181ced49..b6a058ad53 100644 --- a/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts @@ -7,14 +7,18 @@ import type { SemanticMigration } from '../../types.js'; export const entry: SemanticMigration = { id: 'flow-edge-condition-evaluated-slot-source-required', surface: - 'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate ' - + 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an ' - + 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or ' - + 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', ' - + 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable ' - + 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack ' - + 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in ' - + 'sys_metadata', + 'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch ' + + 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition ' + + 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — ' + + 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } ' + + 'with no source), or with a source that is blank after trimming, through the envelope key ' + + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it ' + + '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound ' + + 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather ' + + 'than deriving a second one; it is the same decision reaching the second slot, which is why ' + + 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored ' + + 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, ' + + 'a POST /flows body, and a flow row already sitting in sys_metadata', replacement: 'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare ' + 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the ' @@ -54,23 +58,30 @@ export const entry: SemanticMigration = { + 'repo reading, which is why the notification is registered here rather than skipped. ' + 'ADR-0087, ADR-0032.', acceptanceCriteria: - 'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, ' - + '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no ' - + '`source` key and for a `source` (or bare string) that is empty after trimming. For each ' - + 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the ' - + '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, ' + 'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s ' + + '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger ' + + 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and ' + + 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` ' + + '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the ' + + 'node key unswept, and the node key is the one with no schema in front of it. For each ' + + 'hit decide, per the `replacement` note, whether the condition was meant to branch (author ' + + 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a ' + + '`start` node removal opens the trigger gate rather than preserving it. Two proofs, ' + 'and the second is the one that matters for stored rows. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean: it locates each offender at ' - + '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and ' + + 'files, `objectstack validate` is clean: it locates each offender with the ' + + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at ' + + '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, ' + + "e.g. `node 'gate' (start) condition` — and " + 'an `ast`-only envelope is also reported by the lint path as ' - + '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back ' + + '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that ' + + 'spelling as well. There is no CLI verb that lowers a stored row back ' + 'into a config file, so this proof does not reach a flow that exists only in ' + '`sys_metadata`. (2) Boot the stack and ' + 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot ' + 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed ' + 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and ' - + 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` ' - + 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is ' - + 'unaffected; every edge ' + + 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its ' + + '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same ' + + "slot phrase. A flow that boots without that warn is unaffected; every structural " + 'condition carrying a non-blank `source` parses byte-identically to before.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9c70fcfc79..00a02b1879 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -7840,14 +7840,18 @@ const step18: MigrationStep = { { id: 'flow-edge-condition-evaluated-slot-source-required', surface: - 'a flow edge predicate — edges[].condition on FlowEdgeSchema, the branch predicate ' - + 'AutomationEngine.evaluateCondition runs at every traversal — authored either as an ' - + 'expression envelope carrying only ast ({ dialect: \'cel\', ast: … } with no source), or ' - + 'with a source that is blank after trimming, through the envelope key ({ dialect: \'cel\', ' - + 'source: \' \' }) or the bare-string shorthand for it (condition: \' \'). Reachable ' - + 'wherever a flow is authored or stored: defineStack({ flows }) sources, an exported stack ' - + 'passed to objectstack validate, a POST /flows body, and a flow row already sitting in ' - + 'sys_metadata', + 'a structural flow condition, BOTH slots — edges[].condition on FlowEdgeSchema, the branch ' + + 'predicate AutomationEngine.evaluateCondition runs at every traversal, and config.condition ' + + 'on a flow NODE, which is a decision node predicate and on a start node the trigger gate — ' + + 'authored either as an expression envelope carrying only ast ({ dialect: \'cel\', ast: … } ' + + 'with no source), or with a source that is blank after trimming, through the envelope key ' + + '({ dialect: \'cel\', source: \' \' }) or the bare-string shorthand for it ' + + '(condition: \' \'). The node slot joined this entry with #17322 and #17495, which rebound ' + + 'AutomationEngine.registerFlow and objectstack validate to the edge door\'s own rule rather ' + + 'than deriving a second one; it is the same decision reaching the second slot, which is why ' + + 'it is named here instead of in an entry of its own. Reachable wherever a flow is authored ' + + 'or stored: defineStack({ flows }) sources, an exported stack passed to objectstack validate, ' + + 'a POST /flows body, and a flow row already sitting in sys_metadata', replacement: 'a non-blank `source` — `{ dialect: \'cel\', source: \'record.amount > 10\' }`, or the bare ' + 'string `\'record.amount > 10\'` — if the edge was meant to branch; or REMOVE the ' @@ -7887,24 +7891,31 @@ const step18: MigrationStep = { + 'repo reading, which is why the notification is registered here rather than skipped. ' + 'ADR-0087, ADR-0032.', acceptanceCriteria: - 'Grep every authored `edges[].condition` — `defineStack({ flows })` sources, exported stacks, ' - + '`POST /flows` bodies — and every flow row in `sys_metadata`, for an envelope with no ' - + '`source` key and for a `source` (or bare string) that is empty after trimming. For each ' - + 'hit decide, per the `replacement` note, whether the edge was meant to branch (author the ' - + '`source`) or to be unconditional (remove the key) — do not default to removal. Two proofs, ' + 'Grep every authored structural condition — BOTH `edges[].condition` and a node\'s ' + + '`config.condition` (a `decision` node\'s predicate, and on a `start` node the trigger ' + + 'gate) — in `defineStack({ flows })` sources, exported stacks and `POST /flows` bodies, and ' + + 'every flow row in `sys_metadata`, for an envelope with no `source` key and for a `source` ' + + '(or bare string) that is empty after trimming. ⚠️ Sweeping only the edge key leaves the ' + + 'node key unswept, and the node key is the one with no schema in front of it. For each ' + + 'hit decide, per the `replacement` note, whether the condition was meant to branch (author ' + + 'the `source`) or to be unconditional (remove the key) — do not default to removal; on a ' + + '`start` node removal opens the trigger gate rather than preserving it. Two proofs, ' + 'and the second is the one that matters for stored rows. (1) For a stack authored in config ' - + 'files, `objectstack validate` is clean: it locates each offender at ' - + '`flows.N.edges.N.condition` with the `EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence, and ' + + 'files, `objectstack validate` is clean: it locates each offender with the ' + + '`EVALUATED_EXPRESSION_SOURCE_REQUIRED` sentence — an edge at ' + + '`flows.N.edges.N.condition`, and a node by the slot phrase the structural pass builds, ' + + "e.g. `node 'gate' (start) condition` — and " + 'an `ast`-only envelope is also reported by the lint path as ' - + '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`. There is no CLI verb that lowers a stored row back ' + + '`STRUCTURAL_CONDITION_SHAPE_REFUSAL`, which is the sentence the node slot earns for that ' + + 'spelling as well. There is no CLI verb that lowers a stored row back ' + 'into a config file, so this proof does not reach a flow that exists only in ' + '`sys_metadata`. (2) Boot the stack and ' + 'confirm each flow REGISTERS: no `failed to register flow` warn for it (the three boot ' + 'paths spell it `[Automation] failed to register flow`, `[Automation] flow re-sync: failed ' + 'to register flow` and `[Automation] cold-boot flow bind: failed to register flow`), and ' - + 'its trigger is armed. That warn line IS the locator for a stored row: its `issues[].path` ' - + 'names the offending edge as `edges[N].condition`. A flow that boots without that warn is ' - + 'unaffected; every edge ' + + 'its trigger is armed. That warn line IS the locator for a stored row: for an edge its ' + + '`issues[].path` names `edges[N].condition`, and for a node the refusal carries that same ' + + "slot phrase. A flow that boots without that warn is unaffected; every structural " + 'condition carrying a non-blank `source` parses byte-identically to before.', }, { From 0dc0191a407d8f6e5d4767d75328230601c6607e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 02:25:37 +0000 Subject: [PATCH 2/2] test(spec): record what changed the whitespace-only admission, beside its assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same stale ground as the docblock, three lines from the symbol: the case admitted a blank structural condition as "not authored on both sides", which #15807 and #17322 ended. The two assertions are unchanged and still correct — they pin that this function does NOT carry the blank rule — and the comment now says so instead of restating a ruling that no longer holds. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../src/automation/flow-node-expression-paths.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/automation/flow-node-expression-paths.test.ts b/packages/spec/src/automation/flow-node-expression-paths.test.ts index 44e5a14224..41aef5aba1 100644 --- a/packages/spec/src/automation/flow-node-expression-paths.test.ts +++ b/packages/spec/src/automation/flow-node-expression-paths.test.ts @@ -267,8 +267,13 @@ describe('every pre-#14149 entry resolves byte-identically (the ratchet\'s fixtu it('admits every string — what it SAYS is validateExpression\'s business', () => { expect(structuralConditionRefusal('record.rating >= 4')).toBeUndefined(); expect(structuralConditionRefusal('{record.rating} >= 4')).toBeUndefined(); - // Ruled correct, not a defect: a whitespace-only STRING means "not - // authored" on both sides and stays so. + // Still admitted — but on the SHAPE question only, and no longer because + // the blank is correct. #15662 admitted it as "not authored on both + // sides"; #15807 refused it at the edge door and #17322 rebound the node + // door at `registerFlow`, so a blank structural condition IS a defect + // today. It is refused there by the imported evaluated-slot rule sitting + // BESIDE this one, never by this function — which is exactly what these + // two assertions pin. See the docblock of `structuralConditionRefusal`. expect(structuralConditionRefusal(' ')).toBeUndefined(); expect(structuralConditionRefusal('')).toBeUndefined(); });